@flaredev/core 0.9.1 → 0.10.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.
@@ -91,6 +91,60 @@ var field = {
91
91
  hasMany: (target, options) => ({ kind: "hasMany", target, ...options, required: false })
92
92
  };
93
93
 
94
+ // src/resource/money.ts
95
+ var EXPONENTS = {
96
+ // No minor unit at all.
97
+ BIF: 0,
98
+ CLP: 0,
99
+ DJF: 0,
100
+ GNF: 0,
101
+ ISK: 0,
102
+ JPY: 0,
103
+ KMF: 0,
104
+ KRW: 0,
105
+ PYG: 0,
106
+ RWF: 0,
107
+ UGX: 0,
108
+ UYI: 0,
109
+ VND: 0,
110
+ VUV: 0,
111
+ XAF: 0,
112
+ XOF: 0,
113
+ XPF: 0,
114
+ // Thousandths.
115
+ BHD: 3,
116
+ IQD: 3,
117
+ JOD: 3,
118
+ KWD: 3,
119
+ LYD: 3,
120
+ OMR: 3,
121
+ TND: 3,
122
+ // Ten-thousandths.
123
+ CLF: 4
124
+ };
125
+ var DEFAULT_MONEY_EXPONENT = 2;
126
+ function moneyExponent(currency) {
127
+ if (!currency) return DEFAULT_MONEY_EXPONENT;
128
+ return EXPONENTS[currency.toUpperCase()] ?? DEFAULT_MONEY_EXPONENT;
129
+ }
130
+ var factor = (exponent) => 10 ** exponent;
131
+ function toMinorUnits(amount, currency) {
132
+ if (!Number.isFinite(amount)) throw new TypeError(`Not an amount: ${amount}`);
133
+ return Number(amount.toFixed(moneyExponent(currency)).replace(".", ""));
134
+ }
135
+ function fromMinorUnits(minor, currency) {
136
+ const exponent = moneyExponent(currency);
137
+ if (exponent === 0) return minor;
138
+ return Number((minor / factor(exponent)).toFixed(exponent));
139
+ }
140
+ function hasTooManyPlaces(amount, currency) {
141
+ const exponent = moneyExponent(currency);
142
+ const text = String(amount);
143
+ if (text.includes("e") || text.includes("E")) return true;
144
+ const places = text.includes(".") ? text.length - text.indexOf(".") - 1 : 0;
145
+ return places > exponent;
146
+ }
147
+
94
148
  // src/resource/naming.ts
95
149
  function words(value) {
96
150
  return value.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2").split(/[^A-Za-z0-9]+/).filter(Boolean).map((word) => word.toLowerCase());
@@ -367,7 +421,14 @@ function fieldSchema(def) {
367
421
  if (def.format === "rating") {
368
422
  s = s.min(def.min ?? 0, `At least ${def.min ?? 0}`).max(def.max ?? 5, `At most ${def.max ?? 5}`);
369
423
  }
370
- if (def.format === "money" && def.min === void 0) s = s.min(0, "Cannot be negative");
424
+ if (def.format === "money") {
425
+ if (def.min === void 0) s = s.min(0, "Cannot be negative");
426
+ const places = moneyExponent(def.currency);
427
+ s = s.refine(
428
+ (value) => !hasTooManyPlaces(value, def.currency),
429
+ places === 0 ? "A whole amount, with no decimal places" : `At most ${places} decimal place${places === 1 ? "" : "s"}`
430
+ );
431
+ }
371
432
  if (def.min !== void 0) s = s.min(def.min, `Must be at least ${def.min}`);
372
433
  if (def.max !== void 0) s = s.max(def.max, `Must be at most ${def.max}`);
373
434
  return s;
@@ -501,6 +562,11 @@ export {
501
562
  DEFAULT_FILE_MAX_BYTES,
502
563
  fileMaxBytes,
503
564
  field,
565
+ DEFAULT_MONEY_EXPONENT,
566
+ moneyExponent,
567
+ toMinorUnits,
568
+ fromMinorUnits,
569
+ hasTooManyPlaces,
504
570
  words,
505
571
  pascalCase,
506
572
  camelCase,
package/dist/index.d.ts CHANGED
@@ -285,6 +285,52 @@ declare const checkoutPaid: (session: {
285
285
  /** `8 random lowercase letters`, the suffix Stripe asks for on `integration_identifier`. */
286
286
  declare function randomLetters(length?: number): string;
287
287
 
288
+ /**
289
+ * Money, stored exactly.
290
+ *
291
+ * A `money` field used to be a `float`, and `0.1 + 0.2` is the reason that was a bug
292
+ * waiting for a shop with a till in it. Binary floating point cannot represent most
293
+ * decimal fractions, so a column of them drifts: sum a few thousand prices and the total
294
+ * is wrong by a cent, which is the kind of wrong nobody can explain to an accountant.
295
+ *
296
+ * So the column holds **minor units** — whole cents, whole fils, whole yen — and an
297
+ * integer column is exact. The store converts at the boundary, so everything above it
298
+ * still works in the units people write and read: the API sends 19.99, the form shows
299
+ * 19.99, and only the database sees 1999.
300
+ *
301
+ * Flare's own billing code has always done this (`amount:int`, formatted as `cents / 100`).
302
+ * This brings `money` fields in line with it.
303
+ */
304
+ /** The default when a field names no currency: two places, which is almost everywhere. */
305
+ declare const DEFAULT_MONEY_EXPONENT = 2;
306
+ /** Decimal places for an ISO 4217 code. Unknown codes get two, which is the common case. */
307
+ declare function moneyExponent(currency?: string): number;
308
+ /**
309
+ * A decimal amount as whole minor units: 19.99 → 1999.
310
+ *
311
+ * Through `toFixed` rather than `Math.round(amount * 100)`, because multiplying first adds
312
+ * an error of its own: `2.675 * 100` is 267.50000000000006, which rounds to 268, while the
313
+ * double behind `2.675` is 2.67499999999999982 and rounds to 267. `toFixed` asks the
314
+ * runtime to round the value that is actually there, so this agrees with what every other
315
+ * tool prints for the same number.
316
+ *
317
+ * Neither approach can rescue a value the double never held — `1.005` is 1.00499999999999989
318
+ * and becomes 100, not 101. Nothing short of decimal arithmetic all the way from the client
319
+ * fixes that, which is why the validator refuses an amount with more places than its
320
+ * currency has: the place to catch it is before it is a double at all.
321
+ */
322
+ declare function toMinorUnits(amount: number, currency?: string): number;
323
+ /** Whole minor units as a decimal amount: 1999 → 19.99. */
324
+ declare function fromMinorUnits(minor: number, currency?: string): number;
325
+ /**
326
+ * Whether an amount has more decimal places than its currency has.
327
+ *
328
+ * 19.999 in a two-place currency is not a rounding problem to solve quietly — it is a
329
+ * caller sending something they did not mean, and storing 20.00 instead would be a silent
330
+ * change to a number that matters.
331
+ */
332
+ declare function hasTooManyPlaces(amount: number, currency?: string): boolean;
333
+
288
334
  /**
289
335
  * Zod schemas derived from a descriptor at runtime. Unknown keys are rejected,
290
336
  * so clients can't set `id`, timestamps or undeclared columns (no mass assignment).
@@ -525,10 +571,61 @@ interface SeedContext {
525
571
  insertMany: InsertMany;
526
572
  /** Sample values: names, emails, sentences, dates. */
527
573
  fake: Fake;
574
+ /**
575
+ * Creates a user with a credential account, so you can sign in as them straight away:
576
+ *
577
+ * await seedUser({ email: "ada@example.com", password: "correct-horse-battery", role: "admin" });
578
+ */
579
+ seedUser: SeedUser;
580
+ }
581
+ /** What `seedUser` needs to make somebody who can sign in. */
582
+ interface SeedUserInput {
583
+ email: string;
584
+ /** Stored as the same PBKDF2 hash the sign-up route writes, so sign-in verifies it. */
585
+ password: string;
586
+ name?: string;
587
+ /** Seeded users are verified by default: an unverified one cannot sign in. */
588
+ emailVerified?: boolean;
589
+ /** e.g. "admin", which the dashboard's policies read. */
590
+ role?: string;
528
591
  }
592
+ /** A user row and the credential account row that lets them sign in. */
593
+ interface CredentialUserRows {
594
+ user: {
595
+ id: string;
596
+ name: string;
597
+ email: string;
598
+ emailVerified: boolean;
599
+ role: string | undefined;
600
+ createdAt: Date;
601
+ updatedAt: Date;
602
+ };
603
+ account: {
604
+ id: string;
605
+ accountId: string;
606
+ providerId: string;
607
+ userId: string;
608
+ password: string;
609
+ createdAt: Date;
610
+ updatedAt: Date;
611
+ };
612
+ }
613
+ /**
614
+ * The two rows Better Auth looks for when somebody signs in with a password.
615
+ *
616
+ * `accountId` is the one that is easy to get wrong. It is not the provider's name and it is
617
+ * not the email: for a credential account Better Auth writes the user's own id into that
618
+ * column at sign-up, and looks the account up by it at sign-in. Put the email there instead
619
+ * and everything looks right — the row exists, the hash is correct — but signing in answers
620
+ * "User not found", which is a long afternoon. Both rows are built here so that rule lives
621
+ * in one place rather than in every seed somebody writes by hand.
622
+ */
623
+ declare function credentialUserRows(input: SeedUserInput): Promise<CredentialUserRows>;
624
+ /** Creates a user who can actually sign in, and returns the row. */
625
+ type SeedUser = (user: SeedUserInput) => Promise<CredentialUserRows["user"]>;
529
626
  type Seed = (context: SeedContext) => void | Promise<void>;
530
627
  declare function defineSeed(seed: Seed): Seed;
531
628
 
532
- declare const FLARE_VERSION = "0.9.1";
629
+ declare const FLARE_VERSION = "0.10.0";
533
630
 
534
- export { type BelongsToRelation, type Country, CreateInput, FLARE_VERSION, type Fake, Field, type FormValues, type FormatOptions, type HasManyRelation, type InsertMany, type MailMessage, type MailResult, type Mailer, type MailerOptions, PBKDF2_ITERATIONS, type ReadUrlOptions, RelationError, type RelationGraph, Resource, type ResourceRelations, type ResourceValidators, SNIFF_BYTES, SUBSCRIPTION_STATUSES, type Seed, type SeedContext, type Storage, type StorageBucket, type StorageOptions, StoredField, type SubscriptionLike, type SubscriptionState, type SubscriptionStatus, type Tone, type TransactionalEmail, UpdateInput, type UploadUrlOptions, camelCase, checkoutPaid, countries, countryName, createFake, createMailer, createObjectKey, createStorage, createValidators, currentPeriodEnd, defineSeed, fake, fieldSchema, fileKeyPrefix, flagEmoji, formValuesToInput, formatPhone, formatValue, grantsAccess, hashPassword, humanize, importSigningKey, initialFormValues, isColor, isCountryCode, isDomain, isLiveSubscription, isPhoneNumber, isSlug, issuesByField, kebabCase, matchesContentType, normalizeDomain, optionLabel, parseMultiValue, pascalCase, pluralize, randomLetters, relationGraph, renderTransactionalEmail, shouldApplySubscription, signToken, slugify, snakeCase, sniffMatches, splitPhone, statusTone, subscriptionPriceId, subscriptionState, toE164, toSubscriptionStatus, verifyPassword, verifyToken, words };
631
+ export { type BelongsToRelation, type Country, CreateInput, type CredentialUserRows, DEFAULT_MONEY_EXPONENT, FLARE_VERSION, type Fake, Field, type FormValues, type FormatOptions, type HasManyRelation, type InsertMany, type MailMessage, type MailResult, type Mailer, type MailerOptions, PBKDF2_ITERATIONS, type ReadUrlOptions, RelationError, type RelationGraph, Resource, type ResourceRelations, type ResourceValidators, SNIFF_BYTES, SUBSCRIPTION_STATUSES, type Seed, type SeedContext, type SeedUser, type SeedUserInput, type Storage, type StorageBucket, type StorageOptions, StoredField, type SubscriptionLike, type SubscriptionState, type SubscriptionStatus, type Tone, type TransactionalEmail, UpdateInput, type UploadUrlOptions, camelCase, checkoutPaid, countries, countryName, createFake, createMailer, createObjectKey, createStorage, createValidators, credentialUserRows, currentPeriodEnd, defineSeed, fake, fieldSchema, fileKeyPrefix, flagEmoji, formValuesToInput, formatPhone, formatValue, fromMinorUnits, grantsAccess, hasTooManyPlaces, hashPassword, humanize, importSigningKey, initialFormValues, isColor, isCountryCode, isDomain, isLiveSubscription, isPhoneNumber, isSlug, issuesByField, kebabCase, matchesContentType, moneyExponent, normalizeDomain, optionLabel, parseMultiValue, pascalCase, pluralize, randomLetters, relationGraph, renderTransactionalEmail, shouldApplySubscription, signToken, slugify, snakeCase, sniffMatches, splitPhone, statusTone, subscriptionPriceId, subscriptionState, toE164, toMinorUnits, toSubscriptionStatus, verifyPassword, verifyToken, words };
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  DEFAULT_FILE_MAX_BYTES,
3
+ DEFAULT_MONEY_EXPONENT,
3
4
  FILE_CATEGORIES,
4
5
  NUMBER_FORMATS,
5
6
  PolicyError,
@@ -24,6 +25,8 @@ import {
24
25
  fileMaxBytes,
25
26
  flagEmoji,
26
27
  formatPhone,
28
+ fromMinorUnits,
29
+ hasTooManyPlaces,
27
30
  humanize,
28
31
  isColor,
29
32
  isCountryCode,
@@ -32,6 +35,7 @@ import {
32
35
  isSlug,
33
36
  kebabCase,
34
37
  mimeTypesFor,
38
+ moneyExponent,
35
39
  normalizeDomain,
36
40
  ownershipFilter,
37
41
  pascalCase,
@@ -41,8 +45,9 @@ import {
41
45
  splitPhone,
42
46
  storedFields,
43
47
  toE164,
48
+ toMinorUnits,
44
49
  words
45
- } from "./chunk-AAXNSIZG.js";
50
+ } from "./chunk-TB53VHXY.js";
46
51
 
47
52
  // src/password.ts
48
53
  var ALGORITHM = "pbkdf2-sha256";
@@ -705,6 +710,34 @@ function issuesByField(issues) {
705
710
  }
706
711
 
707
712
  // src/seed.ts
713
+ async function credentialUserRows(input) {
714
+ const email = input.email.trim().toLowerCase();
715
+ if (!email.includes("@")) throw new Error(`seedUser needs an email address, got "${input.email}".`);
716
+ if (!input.password) throw new Error(`seedUser needs a password for ${email}.`);
717
+ const id = crypto.randomUUID();
718
+ const now = /* @__PURE__ */ new Date();
719
+ return {
720
+ user: {
721
+ id,
722
+ name: input.name ?? email.split("@")[0],
723
+ email,
724
+ emailVerified: input.emailVerified ?? true,
725
+ role: input.role,
726
+ createdAt: now,
727
+ updatedAt: now
728
+ },
729
+ account: {
730
+ id: crypto.randomUUID(),
731
+ // Not the email. See above.
732
+ accountId: id,
733
+ providerId: "credential",
734
+ userId: id,
735
+ password: await hashPassword(input.password),
736
+ createdAt: now,
737
+ updatedAt: now
738
+ }
739
+ };
740
+ }
708
741
  function defineSeed(seed) {
709
742
  return seed;
710
743
  }
@@ -1019,9 +1052,10 @@ function createFake(seed = 20260101) {
1019
1052
  var fake = createFake();
1020
1053
 
1021
1054
  // src/index.ts
1022
- var FLARE_VERSION = "0.9.1";
1055
+ var FLARE_VERSION = "0.10.0";
1023
1056
  export {
1024
1057
  DEFAULT_FILE_MAX_BYTES,
1058
+ DEFAULT_MONEY_EXPONENT,
1025
1059
  FILE_CATEGORIES,
1026
1060
  FLARE_VERSION,
1027
1061
  NUMBER_FORMATS,
@@ -1047,6 +1081,7 @@ export {
1047
1081
  createObjectKey,
1048
1082
  createStorage,
1049
1083
  createValidators,
1084
+ credentialUserRows,
1050
1085
  currentPeriodEnd,
1051
1086
  defaultFieldLabel,
1052
1087
  definePolicy,
@@ -1061,7 +1096,9 @@ export {
1061
1096
  formValuesToInput,
1062
1097
  formatPhone,
1063
1098
  formatValue,
1099
+ fromMinorUnits,
1064
1100
  grantsAccess,
1101
+ hasTooManyPlaces,
1065
1102
  hashPassword,
1066
1103
  humanize,
1067
1104
  importSigningKey,
@@ -1076,6 +1113,7 @@ export {
1076
1113
  kebabCase,
1077
1114
  matchesContentType,
1078
1115
  mimeTypesFor,
1116
+ moneyExponent,
1079
1117
  normalizeDomain,
1080
1118
  optionLabel,
1081
1119
  ownershipFilter,
@@ -1096,6 +1134,7 @@ export {
1096
1134
  subscriptionPriceId,
1097
1135
  subscriptionState,
1098
1136
  toE164,
1137
+ toMinorUnits,
1099
1138
  toSubscriptionStatus,
1100
1139
  verifyPassword,
1101
1140
  verifyToken,
package/dist/server.js CHANGED
@@ -2,8 +2,9 @@ import {
2
2
  can,
3
3
  columnName,
4
4
  createValidators,
5
+ moneyExponent,
5
6
  storedFields
6
- } from "./chunk-AAXNSIZG.js";
7
+ } from "./chunk-TB53VHXY.js";
7
8
 
8
9
  // ../../node_modules/.pnpm/drizzle-orm@0.45.2_@cloudfl_21866ab18c10d2d988b916e06105fbce/node_modules/drizzle-orm/entity.js
9
10
  var entityKind = /* @__PURE__ */ Symbol.for("drizzle:entityKind");
@@ -1783,6 +1784,12 @@ function fieldJsonSchema(def) {
1783
1784
  const schema = { ...base, type: def.kind === "int" ? "integer" : "number" };
1784
1785
  if (def.min !== void 0) schema.minimum = def.min;
1785
1786
  if (def.max !== void 0) schema.maximum = def.max;
1787
+ if (def.format === "money") {
1788
+ const places = moneyExponent(def.currency);
1789
+ schema.multipleOf = places === 0 ? 1 : Number(`1e-${places}`);
1790
+ schema.description = base.description ?? `An amount in ${def.currency ?? "the app's currency"}, to ${places} decimal place${places === 1 ? "" : "s"}.`;
1791
+ schema.examples = [places === 0 ? 1500 : 19.99];
1792
+ }
1786
1793
  return schema;
1787
1794
  }
1788
1795
  case "boolean":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flaredev/core",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
4
4
  "description": "Flare runtime: resource descriptors, field grammar, and Cloudflare-native helpers",
5
5
  "license": "MIT",
6
6
  "author": "Muke Johnbaptist",