@flaredev/core 0.11.0 → 0.12.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.
@@ -0,0 +1,68 @@
1
+ // src/problem.ts
2
+ var PROBLEM_CODES = {
3
+ /** The body broke a field's rules. `issues` says which fields. */
4
+ validationFailed: "validation_failed",
5
+ /** A unique column already holds this value. `issues` names the column. */
6
+ uniqueViolation: "unique_violation",
7
+ /** Deleting this would orphan rows that point at it. */
8
+ stillReferenced: "still_referenced",
9
+ /** A relation field names a record that does not exist. */
10
+ relatedRecordMissing: "related_record_missing",
11
+ /** A value outside what its column accepts — an enum option that is not one. */
12
+ valueNotAllowed: "value_not_allowed",
13
+ /** No such record, or none this request may see. */
14
+ notFound: "not_found",
15
+ /** A query parameter is wrong. `issues` names the parameters. */
16
+ invalidQuery: "invalid_query",
17
+ /** The body was not JSON. */
18
+ invalidJson: "invalid_json",
19
+ /** The request did not say `Content-Type: application/json`. */
20
+ unsupportedMediaType: "unsupported_media_type",
21
+ /** A write from another origin: the CSRF guard. */
22
+ crossOriginBlocked: "cross_origin_blocked",
23
+ /** No session and no API key. */
24
+ unauthenticated: "unauthenticated",
25
+ /** Signed in, but this role may not do this. */
26
+ forbidden: "forbidden",
27
+ /** Refused by the security layer before the app saw it. */
28
+ accessDenied: "access_denied",
29
+ /** Too many requests. */
30
+ rateLimited: "rate_limited",
31
+ /** A signed storage URL that is missing, malformed, expired, or not for this operation. */
32
+ invalidToken: "invalid_token",
33
+ /** An upload past the limit the signed URL allows. */
34
+ payloadTooLarge: "payload_too_large",
35
+ /** An upload with no Content-Length, which cannot be checked against that limit. */
36
+ lengthRequired: "length_required",
37
+ /** A request that is wrong in a way none of the others describes. */
38
+ badRequest: "bad_request",
39
+ /** Something went wrong that the server does not explain. */
40
+ internal: "internal"
41
+ };
42
+ var ISSUE_CODES = {
43
+ /** Broke the field's own rules: a format, a length, a required value. */
44
+ invalid: "invalid",
45
+ /** A unique column already holds this. */
46
+ unique: "unique",
47
+ /** Set once and not changeable — an ownership column, for instance. */
48
+ immutable: "immutable",
49
+ /** A query parameter rather than a body field. */
50
+ invalidParameter: "invalid_parameter"
51
+ };
52
+ function wantsJson(request) {
53
+ const accept = request.headers.get("accept") ?? "";
54
+ if (/\bapplication\/json\b/i.test(accept)) return true;
55
+ if (/\btext\/html\b/i.test(accept)) return false;
56
+ return new URL(request.url).pathname.startsWith("/api/");
57
+ }
58
+ function problemResponse(status, error, code, issues, init) {
59
+ const body = issues?.length ? { error, code, issues } : { error, code };
60
+ return Response.json(body, { ...init, status });
61
+ }
62
+
63
+ export {
64
+ PROBLEM_CODES,
65
+ ISSUE_CODES,
66
+ wantsJson,
67
+ problemResponse
68
+ };
@@ -1,3 +1,7 @@
1
+ import {
2
+ parseRate
3
+ } from "./chunk-I2HSIPCF.js";
4
+
1
5
  // src/resource/fields.ts
2
6
  var STRING_FORMATS = [
3
7
  "email",
@@ -160,13 +164,50 @@ var humanize = (value) => {
160
164
  const text = words(value).join(" ");
161
165
  return text ? text[0].toUpperCase() + text.slice(1) : text;
162
166
  };
163
- var UNCOUNTABLE = /* @__PURE__ */ new Set(["equipment", "information", "rice", "money", "species", "series", "fish", "sheep", "data", "news"]);
164
- var IRREGULAR = { person: "people", man: "men", woman: "women", child: "children", mouse: "mice" };
167
+ var UNCOUNTABLE = /* @__PURE__ */ new Set(["equipment", "information", "rice", "money", "species", "series", "fish", "sheep", "data", "news", "staff"]);
168
+ var IRREGULAR = {
169
+ person: "people",
170
+ man: "men",
171
+ woman: "women",
172
+ child: "children",
173
+ mouse: "mice",
174
+ // -f / -fe → -ves
175
+ shelf: "shelves",
176
+ half: "halves",
177
+ knife: "knives",
178
+ life: "lives",
179
+ leaf: "leaves",
180
+ loaf: "loaves",
181
+ wolf: "wolves",
182
+ wife: "wives",
183
+ calf: "calves",
184
+ self: "selves",
185
+ thief: "thieves",
186
+ elf: "elves",
187
+ // Latin endings an app meets in a catalogue or a lab
188
+ index: "indexes",
189
+ matrix: "matrices",
190
+ vertex: "vertices",
191
+ appendix: "appendices",
192
+ analysis: "analyses",
193
+ diagnosis: "diagnoses",
194
+ thesis: "theses",
195
+ basis: "bases",
196
+ crisis: "crises",
197
+ criterion: "criteria",
198
+ curriculum: "curricula",
199
+ syllabus: "syllabuses",
200
+ alumnus: "alumni",
201
+ campus: "campuses",
202
+ status: "statuses",
203
+ quiz: "quizzes"
204
+ };
165
205
  function pluralize(value) {
166
- const match = /([A-Za-z]+)$/.exec(value);
167
- if (!match) return value;
168
- const word = match[1];
169
- const lower = word.toLowerCase();
206
+ if (!/[A-Za-z]$/.test(value)) return value;
207
+ const parts = words(value);
208
+ const lower = parts[parts.length - 1];
209
+ if (!lower) return value;
210
+ const word = value.slice(value.length - lower.length);
170
211
  const head = value.slice(0, value.length - word.length);
171
212
  const keepCase = (plural) => word[0] === word[0].toUpperCase() ? plural[0].toUpperCase() + plural.slice(1) : plural;
172
213
  if (UNCOUNTABLE.has(lower)) return value;
@@ -192,6 +233,17 @@ var ResourceDefinitionError = class extends Error {
192
233
  }
193
234
  };
194
235
  var IDENTIFIER = /^[a-z][A-Za-z0-9]*$/;
236
+ function validateRateLimit(name, config) {
237
+ for (const [action, spec] of Object.entries(config)) {
238
+ if (typeof spec !== "string") continue;
239
+ try {
240
+ parseRate(spec);
241
+ } catch (error) {
242
+ throw new ResourceDefinitionError(name, `rateLimit.${action}: ${error instanceof Error ? error.message : String(error)}`);
243
+ }
244
+ }
245
+ return config;
246
+ }
195
247
  function defaultFieldLabel(key, field2) {
196
248
  return humanize(field2.kind === "belongsTo" ? key.replace(/Id$/, "") : key);
197
249
  }
@@ -237,7 +289,7 @@ function defineResource(config) {
237
289
  fail(`titleField "${config.titleField}" isn't a stored field.`);
238
290
  }
239
291
  const defaultSort = config.defaultSort ?? { field: "createdAt", direction: "desc" };
240
- if (!["createdAt", "updatedAt"].includes(defaultSort.field) && !stored.some(([key]) => key === defaultSort.field)) {
292
+ if (!["id", "createdAt", "updatedAt"].includes(defaultSort.field) && !stored.some(([key]) => key === defaultSort.field)) {
241
293
  fail(`defaultSort field "${defaultSort.field}" isn't a stored field.`);
242
294
  }
243
295
  const perPage = config.perPage ?? 25;
@@ -257,6 +309,9 @@ function defineResource(config) {
257
309
  defaultSort,
258
310
  perPage,
259
311
  softDelete: config.softDelete === true,
312
+ // Parsed here rather than in the route, so a rate that cannot be read is an error at
313
+ // `flare gen` with the descriptor in front of you, not a surprise in production.
314
+ ...config.rateLimit ? { rateLimit: validateRateLimit(config.name, config.rateLimit) } : {},
260
315
  fields,
261
316
  $types: void 0
262
317
  };
@@ -286,6 +341,45 @@ function describeCondition(condition) {
286
341
  return `${condition.field} ${condition.op === "eq" ? "is" : "is not"} ${values[0]}`;
287
342
  }
288
343
 
344
+ // src/resource/enum-member.ts
345
+ var IDENTIFIER_CHAR = /[A-Za-z0-9_]/;
346
+ function prismaEnumMember(option) {
347
+ let member = "";
348
+ for (const char of option) {
349
+ if (IDENTIFIER_CHAR.test(char)) member += char;
350
+ else if (char === "-" || char === " ") member += "_";
351
+ else member += `_${char.codePointAt(0).toString(16)}`;
352
+ }
353
+ return /^\d/.test(member) ? `v_${member}` : member;
354
+ }
355
+ function enumMemberCollision(options) {
356
+ const seen = /* @__PURE__ */ new Map();
357
+ for (const option of options) {
358
+ const member = prismaEnumMember(option);
359
+ const first = seen.get(member);
360
+ if (first !== void 0) return { a: first, b: option, member };
361
+ seen.set(member, option);
362
+ }
363
+ return void 0;
364
+ }
365
+ function enumTranslations(fields) {
366
+ const out = {};
367
+ for (const [key, def] of fields) {
368
+ if (def.kind !== "enum") continue;
369
+ const options = def.options ?? [];
370
+ if (options.every((option) => prismaEnumMember(option) === option)) continue;
371
+ const toMember = {};
372
+ const toValue = {};
373
+ for (const option of options) {
374
+ const member = prismaEnumMember(option);
375
+ toMember[option] = member;
376
+ toValue[member] = option;
377
+ }
378
+ out[key] = { toMember, toValue };
379
+ }
380
+ return out;
381
+ }
382
+
289
383
  // src/resource/formats.ts
290
384
  import {
291
385
  getCountries,
@@ -357,7 +451,27 @@ var isSlug = (value) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value);
357
451
  function slugify(value) {
358
452
  return value.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
359
453
  }
360
- var isIpAddress = (value) => /^(?:\d{1,3}\.){3}\d{1,3}$/.test(value) ? value.split(".").every((part) => Number(part) <= 255) : /^[0-9a-f:]+$/i.test(value) && value.includes(":") && value.split("::").length <= 2;
454
+ function isIpv4(value) {
455
+ if (!/^(?:\d{1,3}\.){3}\d{1,3}$/.test(value)) return false;
456
+ return value.split(".").every((part) => {
457
+ const num = Number(part);
458
+ return num >= 0 && num <= 255;
459
+ });
460
+ }
461
+ function isIpv6(value) {
462
+ if (!value || value.includes(":::")) return false;
463
+ const doubleColonParts = value.split("::");
464
+ if (doubleColonParts.length > 2) return false;
465
+ if (doubleColonParts.length === 2) {
466
+ const left = doubleColonParts[0] ? doubleColonParts[0].split(":") : [];
467
+ const right = doubleColonParts[1] ? doubleColonParts[1].split(":") : [];
468
+ if (left.length + right.length >= 8) return false;
469
+ return [...left, ...right].every((p) => /^[0-9a-f]{1,4}$/i.test(p));
470
+ }
471
+ const parts = value.split(":");
472
+ return parts.length === 8 && parts.every((p) => /^[0-9a-f]{1,4}$/i.test(p));
473
+ }
474
+ var isIpAddress = (value) => isIpv4(value) || isIpv6(value);
361
475
  var isUuid = (value) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
362
476
  function isTimezone(value) {
363
477
  try {
@@ -527,6 +641,211 @@ function createValidators(resource) {
527
641
  };
528
642
  }
529
643
 
644
+ // src/resource/relations.ts
645
+ var RelationError = class extends Error {
646
+ constructor(message) {
647
+ super(message);
648
+ this.name = "RelationError";
649
+ }
650
+ };
651
+ var relationNameFor = (table, key) => `${table}_${columnName(key)}`;
652
+ var AUTH_USER = "User";
653
+ var isAuthUserTarget = (target) => target === AUTH_USER;
654
+ var defaultAuthUserOnDelete = (required) => required ? "restrict" : "set null";
655
+ function relationGraph(resources) {
656
+ const byName = new Map(resources.map((resource) => [resource.name, resource]));
657
+ const graph = { byResource: {}, pending: [] };
658
+ for (const resource of resources) {
659
+ const relations = { belongsTo: [], hasMany: [] };
660
+ for (const [key, def] of storedFields(resource)) {
661
+ if (def.kind !== "belongsTo") continue;
662
+ const authUser = isAuthUserTarget(def.target);
663
+ if (!authUser && !byName.has(def.target)) {
664
+ throw new RelationError(`${resource.name}.${key} belongs to "${def.target}", but there is no ${def.target} resource.`);
665
+ }
666
+ relations.belongsTo.push({
667
+ key,
668
+ name: key.replace(/Id$/, ""),
669
+ target: def.target,
670
+ required: def.required,
671
+ onDelete: def.onDelete ?? (authUser ? defaultAuthUserOnDelete(def.required) : "restrict"),
672
+ relationName: relationNameFor(resource.table, key)
673
+ });
674
+ }
675
+ graph.byResource[resource.name] = relations;
676
+ }
677
+ for (const resource of resources) {
678
+ for (const [key, def] of Object.entries(resource.fields)) {
679
+ if (def.kind !== "hasMany") continue;
680
+ const target = byName.get(def.target);
681
+ if (!target) {
682
+ graph.pending.push({ resource: resource.name, key, target: def.target });
683
+ continue;
684
+ }
685
+ const foreignKey = def.foreignKey ?? `${camelCase(resource.name)}Id`;
686
+ const back = graph.byResource[target.name].belongsTo.find((relation) => relation.key === foreignKey);
687
+ if (!back || back.target !== resource.name) {
688
+ throw new RelationError(
689
+ `${resource.name}.${key} is hasMany(${target.name}), but ${target.name} has no ${foreignKey}: belongsTo(${resource.name}) field. Add it to ${target.name}, or set foreignKey to the field that points back.`
690
+ );
691
+ }
692
+ graph.byResource[resource.name].hasMany.push({ key, target: target.name, foreignKey, relationName: back.relationName });
693
+ }
694
+ }
695
+ for (const resource of resources) {
696
+ const { belongsTo, hasMany } = graph.byResource[resource.name];
697
+ const taken = new Set(Object.keys(resource.fields));
698
+ for (const { key, name } of belongsTo) {
699
+ if (taken.has(name)) {
700
+ throw new RelationError(`${resource.name}.${key} exposes relation "${name}", which collides with the ${resource.name}.${name} field. Rename one of them.`);
701
+ }
702
+ taken.add(name);
703
+ }
704
+ for (const { key } of hasMany) {
705
+ if (belongsTo.some((relation) => relation.name === key)) {
706
+ throw new RelationError(`${resource.name}.${key} collides with a belongsTo relation of the same name.`);
707
+ }
708
+ }
709
+ }
710
+ return graph;
711
+ }
712
+
713
+ // src/resource/display.ts
714
+ var TONES = [
715
+ ["success", /^(paid|fulfilled|active|approved|completed?|delivered|done|published|resolved|shipped|succeeded|success|verified|won)$/],
716
+ ["danger", /^(banned|blocked|cancel+ed|churned|declined|error|expired|failed|lost|overdue|refunded|rejected|suspended)$/],
717
+ ["warning", /^(awaiting|draft|in[-_ ]?review|on[-_ ]?hold|pending|processing|review|trial|unpaid|unverified)$/]
718
+ ];
719
+ function statusTone(value) {
720
+ if (typeof value !== "string") return "neutral";
721
+ const normalized = value.trim().toLowerCase();
722
+ return TONES.find(([, pattern]) => pattern.test(normalized))?.[0] ?? "neutral";
723
+ }
724
+ function optionLabel(def, value) {
725
+ if ((def.kind === "enum" || def.kind === "multiselect") && def.optionLabels?.[value]) return def.optionLabels[value];
726
+ const text = value.replace(/[_-]+/g, " ").trim();
727
+ return text ? text[0].toUpperCase() + text.slice(1) : value;
728
+ }
729
+ function formatValue(def, value, options = {}) {
730
+ if (value === null || value === void 0 || value === "") return "\u2014";
731
+ const locale = options.locale ?? "en-US";
732
+ switch (def.kind) {
733
+ case "boolean":
734
+ return value ? "Yes" : "No";
735
+ case "int":
736
+ return new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }).format(Number(value));
737
+ case "float":
738
+ return new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format(Number(value));
739
+ case "date": {
740
+ const date = /* @__PURE__ */ new Date(`${String(value)}T00:00:00Z`);
741
+ return Number.isNaN(date.getTime()) ? String(value) : new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeZone: "UTC" }).format(date);
742
+ }
743
+ case "datetime":
744
+ case "timestamp": {
745
+ const date = value instanceof Date ? value : new Date(value);
746
+ return Number.isNaN(date.getTime()) ? String(value) : new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short", timeZone: options.timeZone ?? "UTC" }).format(date);
747
+ }
748
+ case "enum":
749
+ return optionLabel(def, String(value));
750
+ case "multiselect":
751
+ return Array.isArray(value) && value.length ? value.map((item) => optionLabel(def, String(item))).join(", ") : "\u2014";
752
+ case "tags":
753
+ return Array.isArray(value) && value.length ? value.map(String).join(", ") : "\u2014";
754
+ case "json": {
755
+ if (value === null || value === void 0) return "\u2014";
756
+ const text = typeof value === "string" ? value : JSON.stringify(value);
757
+ return text.length > 60 ? `${text.slice(0, 59)}\u2026` : text;
758
+ }
759
+ case "string":
760
+ if (def.format === "tel") return formatPhone(String(value));
761
+ if (def.format === "country") return `${flagEmoji(String(value))} ${countryName(String(value), locale.split("-")[0])}`.trim();
762
+ return String(value);
763
+ case "file":
764
+ return String(value).split("/").pop().replace(/^[0-9a-f-]{36}-/, "");
765
+ default:
766
+ return String(value);
767
+ }
768
+ }
769
+
770
+ // src/resource/form.ts
771
+ function initialFormValues(resource, record) {
772
+ const values = {};
773
+ for (const [key, def] of storedFields(resource)) {
774
+ const source = record ? record[key] : "default" in def ? def.default : void 0;
775
+ if (def.kind === "boolean") values[key] = source === true || source === 1;
776
+ else if (def.kind === "multiselect" || def.kind === "tags") values[key] = JSON.stringify(Array.isArray(source) ? source : []);
777
+ else if (def.kind === "json") values[key] = source === null || source === void 0 ? "" : JSON.stringify(source, null, 2);
778
+ else if (source === null || source === void 0) values[key] = "";
779
+ else if (source instanceof Date) values[key] = source.toISOString();
780
+ else values[key] = String(source);
781
+ }
782
+ return values;
783
+ }
784
+ function formValuesToInput(resource, values, mode) {
785
+ const input = {};
786
+ for (const [key, def] of storedFields(resource)) {
787
+ const raw = values[key];
788
+ if (def.kind === "boolean") {
789
+ input[key] = raw === true;
790
+ continue;
791
+ }
792
+ if (def.kind === "multiselect" || def.kind === "tags") {
793
+ const picked = parseMultiValue(raw);
794
+ if (picked.length === 0 && mode === "create" && "default" in def && def.default !== void 0) continue;
795
+ input[key] = picked.length === 0 && !def.required ? null : picked;
796
+ continue;
797
+ }
798
+ if (def.kind === "json") {
799
+ const source = typeof raw === "string" ? raw.trim() : "";
800
+ if (source === "") {
801
+ if (mode === "create" && "default" in def && def.default !== void 0) continue;
802
+ if (!def.required) input[key] = null;
803
+ continue;
804
+ }
805
+ try {
806
+ input[key] = JSON.parse(source);
807
+ } catch {
808
+ input[key] = Number.NaN;
809
+ }
810
+ continue;
811
+ }
812
+ const text = typeof raw === "string" ? raw : "";
813
+ const empty = text.trim() === "";
814
+ if (empty) {
815
+ const hasDefault = "default" in def && def.default !== void 0;
816
+ if (mode === "create" && hasDefault) continue;
817
+ if (!def.required) input[key] = null;
818
+ else if (def.kind === "string" || def.kind === "text") input[key] = text;
819
+ continue;
820
+ }
821
+ if (def.kind === "int" || def.kind === "float") {
822
+ const number = Number(text.trim());
823
+ input[key] = Number.isFinite(number) ? number : text;
824
+ continue;
825
+ }
826
+ input[key] = text;
827
+ }
828
+ return input;
829
+ }
830
+ function parseMultiValue(raw) {
831
+ if (typeof raw !== "string" || raw === "") return [];
832
+ try {
833
+ const parsed = JSON.parse(raw);
834
+ return Array.isArray(parsed) ? parsed.filter((value) => typeof value === "string") : [];
835
+ } catch {
836
+ return [];
837
+ }
838
+ }
839
+ function issuesByField(issues) {
840
+ const errors = {};
841
+ for (const issue of issues) {
842
+ const path = Array.isArray(issue.path) ? issue.path.map(String).join(".") : issue.path;
843
+ const key = path.split(".")[0] || "_form";
844
+ errors[key] ??= issue.message;
845
+ }
846
+ return errors;
847
+ }
848
+
530
849
  // src/resource/policy.ts
531
850
  var ROLE = /^(\*|[a-z][a-z0-9_-]{0,31})$/;
532
851
  var FIELD = /^[a-z][A-Za-z0-9]*$/;
@@ -618,6 +937,9 @@ export {
618
937
  columnName,
619
938
  conditionHolds,
620
939
  describeCondition,
940
+ prismaEnumMember,
941
+ enumMemberCollision,
942
+ enumTranslations,
621
943
  flagEmoji,
622
944
  countryName,
623
945
  countries,
@@ -634,6 +956,18 @@ export {
634
956
  fieldSchema,
635
957
  fileKeyPrefix,
636
958
  createValidators,
959
+ RelationError,
960
+ AUTH_USER,
961
+ isAuthUserTarget,
962
+ defaultAuthUserOnDelete,
963
+ relationGraph,
964
+ statusTone,
965
+ optionLabel,
966
+ formatValue,
967
+ initialFormValues,
968
+ formValuesToInput,
969
+ parseMultiValue,
970
+ issuesByField,
637
971
  PolicyError,
638
972
  definePolicy,
639
973
  can,
@@ -0,0 +1,65 @@
1
+ // src/rate-limit.ts
2
+ var WINDOWS = {
3
+ s: 1,
4
+ sec: 1,
5
+ secs: 1,
6
+ second: 1,
7
+ seconds: 1,
8
+ m: 60,
9
+ min: 60,
10
+ mins: 60,
11
+ minute: 60,
12
+ minutes: 60,
13
+ h: 3600,
14
+ hr: 3600,
15
+ hour: 3600,
16
+ hours: 3600,
17
+ d: 86400,
18
+ day: 86400,
19
+ days: 86400
20
+ };
21
+ var RateSyntaxError = class extends Error {
22
+ constructor(message) {
23
+ super(message);
24
+ this.name = "RateSyntaxError";
25
+ }
26
+ };
27
+ function parseRate(spec) {
28
+ const match = /^\s*(\d+)\s*\/\s*(\d*)\s*([a-z]+)\s*$/i.exec(spec);
29
+ if (!match) {
30
+ throw new RateSyntaxError(`"${spec}" is not a rate. Write it as calls/window, e.g. "10/min", "5/s" or "30/5min".`);
31
+ }
32
+ const limit = Number(match[1]);
33
+ const count = match[2] === "" ? 1 : Number(match[2]);
34
+ const unit = WINDOWS[match[3].toLowerCase()];
35
+ if (unit === void 0) {
36
+ throw new RateSyntaxError(`"${spec}" uses an unknown window "${match[3]}". Use s, min, hour or day.`);
37
+ }
38
+ if (limit < 1) throw new RateSyntaxError(`"${spec}" allows no calls at all. To forbid an action, use a policy instead of a rate limit.`);
39
+ if (count < 1) throw new RateSyntaxError(`"${spec}" has a window of zero.`);
40
+ return { limit, seconds: count * unit };
41
+ }
42
+ function rateFor(config, action) {
43
+ if (!config) return void 0;
44
+ const spec = config[action] ?? config["*"];
45
+ return spec === void 0 ? void 0 : parseRate(spec);
46
+ }
47
+ function ratesIn(config) {
48
+ if (!config) return [];
49
+ return Object.entries(config).filter(([, spec]) => typeof spec === "string").map(([action, spec]) => ({ action, spec, rate: parseRate(spec) }));
50
+ }
51
+ var rateLimitKey = (resource, action, caller) => `${resource}:${action}:${caller}`;
52
+ async function fixedWindow(rate, increment) {
53
+ const { count, ttl } = await increment(rate.seconds);
54
+ const remaining = Math.max(0, rate.limit - count);
55
+ return { allowed: count <= rate.limit, remaining, retryAfter: Math.max(1, Math.ceil(ttl)) };
56
+ }
57
+
58
+ export {
59
+ RateSyntaxError,
60
+ parseRate,
61
+ rateFor,
62
+ ratesIn,
63
+ rateLimitKey,
64
+ fixedWindow
65
+ };