@flaredev/core 0.2.0 → 0.4.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.
@@ -152,6 +152,9 @@ function defineResource(config) {
152
152
  label: config.label ?? humanize(name),
153
153
  pluralLabel: config.pluralLabel ?? humanize(pluralName),
154
154
  icon: config.icon,
155
+ group: config.group,
156
+ hooks: config.hooks ?? {},
157
+ computed: config.computed ?? {},
155
158
  titleField,
156
159
  defaultSort,
157
160
  perPage,
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { F as Field, C as CreateInput, U as UpdateInput, R as Resource, S as StoredField } from './policy-BqRg9RVM.js';
2
- export { B as BelongsToField, a as BooleanField, D as DEFAULT_FILE_MAX_BYTES, b as DateField, c as DateTimeField, E as EnumField, d as FILE_CATEGORIES, e as FieldKind, f as FieldValue, g as FileCategory, h as FileField, i as FloatField, H as HasManyField, I as IntField, M as MultiSelectField, P as Policy, j as PolicyAction, k as PolicyConfig, l as PolicyError, m as RESERVED_FIELD_NAMES, n as RecordOf, o as ResourceConfig, p as ResourceDefinitionError, q as STRING_FORMATS, r as StringField, s as StringFormat, T as TextField, t as allowedActions, u as can, v as columnName, w as defaultFieldLabel, x as definePolicy, y as defineResource, z as field, A as fileMaxBytes, G as mimeTypesFor, J as storedFields } from './policy-BqRg9RVM.js';
1
+ import { F as Field, C as CreateInput, U as UpdateInput, R as Resource, S as StoredField } from './policy-CvfE-wCK.js';
2
+ export { B as BelongsToField, a as BooleanField, D as DEFAULT_FILE_MAX_BYTES, b as DateField, c as DateTimeField, E as EnumField, d as FILE_CATEGORIES, e as FieldKind, f as FieldValue, g as FileCategory, h as FileField, i as FloatField, H as HasManyField, I as IntField, M as MultiSelectField, P as Policy, j as PolicyAction, k as PolicyConfig, l as PolicyError, m as RESERVED_FIELD_NAMES, n as RecordOf, o as ResourceConfig, p as ResourceDefinitionError, q as STRING_FORMATS, r as StringField, s as StringFormat, T as TextField, t as allowedActions, u as can, v as columnName, w as defaultFieldLabel, x as definePolicy, y as defineResource, z as field, A as fileMaxBytes, G as mimeTypesFor, J as storedFields } from './policy-CvfE-wCK.js';
3
3
  import { z } from 'zod';
4
4
 
5
5
  /**
@@ -413,26 +413,100 @@ declare const isSlug: (value: string) => boolean;
413
413
  /** "My First Post!" → "my-first-post". */
414
414
  declare function slugify(value: string): string;
415
415
 
416
+ /**
417
+ * Sample data for seeds. Small on purpose: a handful of word lists and a seeded
418
+ * random number generator, so a million rows cost nothing to produce and the same
419
+ * seed always gives the same data.
420
+ *
421
+ * const fake = createFake();
422
+ * fake.fullName(); // "Amina Okello"
423
+ * fake.email(); // "amina.okello4@example.com"
424
+ */
425
+ interface Fake {
426
+ /** Whole number in [min, max]. */
427
+ int(min: number, max: number): number;
428
+ /** Number in [min, max], rounded to `decimals` places (default 2). */
429
+ float(min: number, max: number, decimals?: number): number;
430
+ /** True with the given probability (default 0.5). */
431
+ bool(chance?: number): boolean;
432
+ pick<T>(items: readonly T[]): T;
433
+ /** Between `min` and `max` distinct items, in the order they appear. */
434
+ some<T>(items: readonly T[], min?: number, max?: number): T[];
435
+ firstName(): string;
436
+ lastName(): string;
437
+ fullName(): string;
438
+ /** A unique address: the counter keeps it unique however many rows are made. */
439
+ email(name?: string): string;
440
+ company(): string;
441
+ jobTitle(): string;
442
+ city(): string;
443
+ /** ISO 3166-1 alpha-2, e.g. "UG". */
444
+ country(): string;
445
+ /** E.164, e.g. "+256772431980". */
446
+ phone(): string;
447
+ domain(): string;
448
+ url(): string;
449
+ slug(text?: string): string;
450
+ color(): string;
451
+ words(count?: number): string;
452
+ sentence(): string;
453
+ paragraph(sentences?: number): string;
454
+ /** "YYYY-MM-DD" within the last `daysBack` days (default 365). */
455
+ date(daysBack?: number): string;
456
+ /** ISO timestamp within the last `daysBack` days (default 365). */
457
+ datetime(daysBack?: number): string;
458
+ /** Milliseconds since the epoch, within the last `daysBack` days. */
459
+ timestamp(daysBack?: number): number;
460
+ uuid(): string;
461
+ /**
462
+ * A ULID: a 26-character id that sorts by the time it was made. Rows seeded with
463
+ * these go in at the end of the primary key's index instead of all over it, which
464
+ * measured 4× quicker than random ids at a few hundred thousand rows.
465
+ */
466
+ id(): string;
467
+ /** How many values this generator has produced; unique per row. */
468
+ readonly count: number;
469
+ }
470
+ /**
471
+ * A generator of sample values. `seed` makes runs reproducible; leave it out for a
472
+ * different set every time.
473
+ */
474
+ declare function createFake(seed?: number): Fake;
475
+ /** A shared generator, for seeds that don't need their own. */
476
+ declare const fake: Fake;
477
+
416
478
  /**
417
479
  * Seed files: `seeds/<name>.seed.ts`, run by `flare seed` against the local D1 database.
418
480
  *
419
481
  * import { defineSeed } from "@flaredev/core";
420
482
  * import { contacts } from "@/db/schema";
421
483
  *
422
- * export default defineSeed(async ({ db }) => {
484
+ * export default defineSeed(async ({ db, insertMany, fake }) => {
423
485
  * await db.insert(contacts).values([{ name: "Ada", email: "ada@example.com" }]);
486
+ * await insertMany(contacts, 10_000, () => ({ name: fake.fullName(), email: fake.email() }));
424
487
  * });
425
488
  */
489
+
490
+ /**
491
+ * Insert many rows at once. `rows` is either the rows themselves or a function called
492
+ * once per row, which keeps a million rows out of memory. Far quicker than a Drizzle
493
+ * insert of the same size: the values go into the SQL rather than into D1's
494
+ * 100-parameter budget.
495
+ */
496
+ type InsertMany = <T extends object>(table: unknown, count: number | readonly T[], build?: (index: number) => T) => Promise<number>;
426
497
  interface SeedContext {
427
498
  /** Drizzle client over the app's schema, bound to the local D1 database. */
428
499
  db: any;
429
500
  /** All local bindings and variables (D1, R2, .dev.vars). */
430
501
  env: Record<string, unknown>;
431
502
  log: (message: string) => void;
503
+ insertMany: InsertMany;
504
+ /** Sample values: names, emails, sentences, dates. */
505
+ fake: Fake;
432
506
  }
433
507
  type Seed = (context: SeedContext) => void | Promise<void>;
434
508
  declare function defineSeed(seed: Seed): Seed;
435
509
 
436
- declare const FLARE_VERSION = "0.2.0";
510
+ declare const FLARE_VERSION = "0.4.0";
437
511
 
438
- export { type BelongsToRelation, type Country, CreateInput, FLARE_VERSION, Field, type FormValues, type FormatOptions, type HasManyRelation, 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, createMailer, createObjectKey, createStorage, createValidators, currentPeriodEnd, defineSeed, 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 };
512
+ 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 };
package/dist/index.js CHANGED
@@ -38,7 +38,7 @@ import {
38
38
  storedFields,
39
39
  toE164,
40
40
  words
41
- } from "./chunk-434JXSDV.js";
41
+ } from "./chunk-PWP4QEZV.js";
42
42
 
43
43
  // src/password.ts
44
44
  var ALGORITHM = "pbkdf2-sha256";
@@ -673,8 +673,296 @@ function defineSeed(seed) {
673
673
  return seed;
674
674
  }
675
675
 
676
+ // src/fake.ts
677
+ var FIRST_NAMES = [
678
+ "Amina",
679
+ "Daniel",
680
+ "Grace",
681
+ "Wei",
682
+ "Sofia",
683
+ "Tendai",
684
+ "Noah",
685
+ "Priya",
686
+ "Lucas",
687
+ "Fatima",
688
+ "Ibrahim",
689
+ "Elena",
690
+ "Kwame",
691
+ "Maya",
692
+ "Hiroshi",
693
+ "Leila",
694
+ "Marcus",
695
+ "Chiara",
696
+ "Omar",
697
+ "Ana",
698
+ "Joseph",
699
+ "Nadia",
700
+ "Thabo",
701
+ "Yuki",
702
+ "Diego",
703
+ "Ruth",
704
+ "Samuel",
705
+ "Zara",
706
+ "Peter",
707
+ "Isabel",
708
+ "Kofi",
709
+ "Mei",
710
+ "Andr\xE9s",
711
+ "Halima",
712
+ "Jonas",
713
+ "Rania",
714
+ "Esther",
715
+ "Viktor",
716
+ "Aisha",
717
+ "Tom\xE1s"
718
+ ];
719
+ var LAST_NAMES = [
720
+ "Okello",
721
+ "Silva",
722
+ "Nakamura",
723
+ "Mensah",
724
+ "Okafor",
725
+ "Rossi",
726
+ "Chen",
727
+ "Hassan",
728
+ "M\xFCller",
729
+ "Dubois",
730
+ "Kimani",
731
+ "Patel",
732
+ "Novak",
733
+ "Garcia",
734
+ "Haddad",
735
+ "Andersen",
736
+ "Osei",
737
+ "Reyes",
738
+ "Kowalski",
739
+ "Tadesse",
740
+ "Nguyen",
741
+ "Baptiste",
742
+ "Mwangi",
743
+ "Fernandes",
744
+ "Ivanov",
745
+ "Khan",
746
+ "Lindqvist",
747
+ "Santos",
748
+ "Abebe",
749
+ "Yusuf"
750
+ ];
751
+ var COMPANY_HEADS = [
752
+ "North",
753
+ "Blue",
754
+ "Bright",
755
+ "Iron",
756
+ "River",
757
+ "Summit",
758
+ "Atlas",
759
+ "Harbour",
760
+ "Cedar",
761
+ "Copper",
762
+ "Lumen",
763
+ "Orbit",
764
+ "Stone",
765
+ "Vertex",
766
+ "Willow",
767
+ "Zenith",
768
+ "Kestrel",
769
+ "Meridian",
770
+ "Sable",
771
+ "Terra"
772
+ ];
773
+ var COMPANY_TAILS = ["Labs", "Works", "Group", "Studio", "Systems", "Partners", "Supply", "Analytics", "Foods", "Logistics"];
774
+ var CITIES = [
775
+ "Kampala",
776
+ "Nairobi",
777
+ "Lagos",
778
+ "Accra",
779
+ "Cairo",
780
+ "Cape Town",
781
+ "Dar es Salaam",
782
+ "Casablanca",
783
+ "London",
784
+ "Berlin",
785
+ "Madrid",
786
+ "Lisbon",
787
+ "Warsaw",
788
+ "Stockholm",
789
+ "Amsterdam",
790
+ "Dublin",
791
+ "New York",
792
+ "Toronto",
793
+ "Chicago",
794
+ "Austin",
795
+ "S\xE3o Paulo",
796
+ "Bogot\xE1",
797
+ "Mexico City",
798
+ "Lima",
799
+ "Mumbai",
800
+ "Singapore",
801
+ "Tokyo",
802
+ "Seoul",
803
+ "Jakarta",
804
+ "Manila",
805
+ "Sydney",
806
+ "Auckland"
807
+ ];
808
+ var COUNTRIES = [
809
+ ["UG", "256"],
810
+ ["KE", "254"],
811
+ ["NG", "234"],
812
+ ["GH", "233"],
813
+ ["ZA", "27"],
814
+ ["EG", "20"],
815
+ ["US", "1"],
816
+ ["CA", "1"],
817
+ ["GB", "44"],
818
+ ["IE", "353"],
819
+ ["DE", "49"],
820
+ ["FR", "33"],
821
+ ["ES", "34"],
822
+ ["PT", "351"],
823
+ ["NL", "31"],
824
+ ["SE", "46"],
825
+ ["PL", "48"],
826
+ ["BR", "55"],
827
+ ["MX", "52"],
828
+ ["IN", "91"],
829
+ ["SG", "65"],
830
+ ["JP", "81"],
831
+ ["AU", "61"],
832
+ ["AE", "971"]
833
+ ];
834
+ var WORDS = [
835
+ "account",
836
+ "balance",
837
+ "shipment",
838
+ "invoice",
839
+ "review",
840
+ "signal",
841
+ "request",
842
+ "channel",
843
+ "vendor",
844
+ "budget",
845
+ "report",
846
+ "delivery",
847
+ "contract",
848
+ "renewal",
849
+ "forecast",
850
+ "meeting",
851
+ "proposal",
852
+ "discount",
853
+ "refund",
854
+ "ticket",
855
+ "quarter",
856
+ "pipeline",
857
+ "customer",
858
+ "supplier",
859
+ "warehouse",
860
+ "campaign",
861
+ "segment",
862
+ "retention",
863
+ "onboarding",
864
+ "escalation",
865
+ "follow-up",
866
+ "handover",
867
+ "kickoff",
868
+ "milestone",
869
+ "rollout",
870
+ "shortfall",
871
+ "surplus",
872
+ "threshold",
873
+ "variance",
874
+ "workflow"
875
+ ];
876
+ var DOMAINS = ["example.com", "example.org", "example.net", "mail.example.com"];
877
+ var TLDS = ["com", "io", "co", "org", "net"];
878
+ var COLORS = ["#f2541d", "#2563eb", "#16a34a", "#d97706", "#7c3aed", "#0891b2", "#db2777", "#4b5563"];
879
+ var DAY_MS = 864e5;
880
+ var CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
881
+ function encodeCrockford(value, length) {
882
+ let out = "";
883
+ let rest = value;
884
+ for (let i = 0; i < length; i++) {
885
+ out = CROCKFORD[Number(rest % 32n)] + out;
886
+ rest /= 32n;
887
+ }
888
+ return out;
889
+ }
890
+ function random(seed) {
891
+ let state = seed >>> 0;
892
+ return () => {
893
+ state = state + 1831565813 >>> 0;
894
+ let t = Math.imul(state ^ state >>> 15, 1 | state);
895
+ t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
896
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
897
+ };
898
+ }
899
+ function createFake(seed = 20260101) {
900
+ const next = random(seed);
901
+ let count = 0;
902
+ const step = () => ++count;
903
+ const tail = BigInt(Math.floor(next() * 2 ** 40)) * 2n ** 40n;
904
+ let counter = 0n;
905
+ const int = (min, max) => min + Math.floor(next() * (max - min + 1));
906
+ const pick = (items) => items[Math.floor(next() * items.length)];
907
+ const slugify2 = (text2) => text2.toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
908
+ const fake2 = {
909
+ int,
910
+ float: (min, max, decimals = 2) => Number((min + next() * (max - min)).toFixed(decimals)),
911
+ bool: (chance = 0.5) => next() < chance,
912
+ pick,
913
+ some(items, min = 1, max = items.length) {
914
+ const wanted = int(Math.min(min, items.length), Math.min(max, items.length));
915
+ const chosen = /* @__PURE__ */ new Set();
916
+ while (chosen.size < wanted) chosen.add(Math.floor(next() * items.length));
917
+ return [...chosen].sort((a, b) => a - b).map((index) => items[index]);
918
+ },
919
+ firstName: () => pick(FIRST_NAMES),
920
+ lastName: () => pick(LAST_NAMES),
921
+ fullName: () => `${pick(FIRST_NAMES)} ${pick(LAST_NAMES)}`,
922
+ email(name) {
923
+ const local = name ? slugify2(name).replace(/-/g, ".") : `${pick(FIRST_NAMES)}.${pick(LAST_NAMES)}`.toLowerCase();
924
+ return `${slugify2(local).replace(/-/g, ".")}${step()}@${pick(DOMAINS)}`;
925
+ },
926
+ company: () => `${pick(COMPANY_HEADS)} ${pick(COMPANY_TAILS)}`,
927
+ jobTitle: () => `${pick(["Head of", "Senior", "Lead", "Regional", "Junior"])} ${pick(["Operations", "Sales", "Support", "Engineering", "Finance", "Logistics"])}`,
928
+ city: () => pick(CITIES),
929
+ country: () => pick(COUNTRIES)[0],
930
+ phone: () => `+${pick(COUNTRIES)[1]}${String(int(7e8, 799999999))}`,
931
+ domain: () => `${slugify2(`${pick(COMPANY_HEADS)}${pick(COMPANY_TAILS)}`)}${step()}.${pick(TLDS)}`,
932
+ url: () => `https://${slugify2(pick(COMPANY_HEADS))}.${pick(TLDS)}/${slugify2(pick(WORDS))}`,
933
+ slug: (text2) => `${slugify2(text2 ?? `${pick(WORDS)}-${pick(WORDS)}`)}-${step()}`,
934
+ color: () => pick(COLORS),
935
+ words: (howMany = 3) => Array.from({ length: howMany }, () => pick(WORDS)).join(" "),
936
+ sentence() {
937
+ const words2 = Array.from({ length: int(5, 12) }, () => pick(WORDS)).join(" ");
938
+ return `${words2[0].toUpperCase()}${words2.slice(1)}.`;
939
+ },
940
+ paragraph(sentences = 3) {
941
+ return Array.from({ length: sentences }, () => fake2.sentence()).join(" ");
942
+ },
943
+ timestamp: (daysBack = 365) => Date.now() - int(0, daysBack) * DAY_MS - int(0, DAY_MS - 1),
944
+ date: (daysBack = 365) => new Date(fake2.timestamp(daysBack)).toISOString().slice(0, 10),
945
+ datetime: (daysBack = 365) => new Date(fake2.timestamp(daysBack)).toISOString(),
946
+ uuid() {
947
+ const hex = "0123456789abcdef";
948
+ let out = "";
949
+ for (let i = 0; i < 32; i++) out += hex[Math.floor(next() * 16)];
950
+ return `${out.slice(0, 8)}-${out.slice(8, 12)}-4${out.slice(13, 16)}-a${out.slice(17, 20)}-${out.slice(20, 32)}`;
951
+ },
952
+ id() {
953
+ counter += 1n;
954
+ return encodeCrockford(BigInt(Date.now()), 10) + encodeCrockford((tail + counter) % 2n ** 80n, 16);
955
+ },
956
+ get count() {
957
+ return count;
958
+ }
959
+ };
960
+ return fake2;
961
+ }
962
+ var fake = createFake();
963
+
676
964
  // src/index.ts
677
- var FLARE_VERSION = "0.2.0";
965
+ var FLARE_VERSION = "0.4.0";
678
966
  export {
679
967
  DEFAULT_FILE_MAX_BYTES,
680
968
  FILE_CATEGORIES,
@@ -694,6 +982,7 @@ export {
694
982
  columnName,
695
983
  countries,
696
984
  countryName,
985
+ createFake,
697
986
  createMailer,
698
987
  createObjectKey,
699
988
  createStorage,
@@ -703,6 +992,7 @@ export {
703
992
  definePolicy,
704
993
  defineResource,
705
994
  defineSeed,
995
+ fake,
706
996
  field,
707
997
  fieldSchema,
708
998
  fileKeyPrefix,
@@ -223,6 +223,11 @@ interface ResourceConfig<Fields extends Record<string, Field>> {
223
223
  pluralLabel?: string;
224
224
  /** lucide-react icon name for navigation, e.g. "users". Default: a generic document icon. */
225
225
  icon?: string;
226
+ /**
227
+ * Heading this resource sits under in the dashboard sidebar, e.g. "Sales". Resources
228
+ * without one share a "Resources" group, which is all a small app needs.
229
+ */
230
+ group?: string;
226
231
  /** Field used to represent a record in lists and relation pickers. Default: first string field. */
227
232
  titleField?: FieldKey<Fields>;
228
233
  defaultSort?: {
@@ -231,6 +236,49 @@ interface ResourceConfig<Fields extends Record<string, Field>> {
231
236
  };
232
237
  /** Page size for list endpoints and tables. Default 25. */
233
238
  perPage?: number;
239
+ /**
240
+ * Your logic, beside the fields it belongs to. Every write — the REST API, the
241
+ * dashboard's forms, a seed, an import — goes through the same store, so a hook here
242
+ * runs for all of them, and there's one place to read to know what happens on a write.
243
+ */
244
+ hooks?: ResourceHooks;
245
+ /**
246
+ * Values worked out from the record rather than stored: a total from a quantity and a
247
+ * price, a full name from two columns. They're added to every record the API and the
248
+ * dashboard return, and there's no column and no migration behind them.
249
+ */
250
+ computed?: Record<string, (record: Record<string, unknown>) => unknown>;
251
+ }
252
+ /** What a hook is handed: the request's own database and who is making it. */
253
+ interface HookContext {
254
+ /** Drizzle, already bound to this request. */
255
+ db: any;
256
+ /** The signed-in user, when the write came from somewhere that has one. */
257
+ user?: {
258
+ id: string;
259
+ email: string;
260
+ role?: string | null;
261
+ } | null;
262
+ }
263
+ interface ResourceHooks {
264
+ /** Change or check the input before it's written. Return the input (changed or not), or throw to refuse. */
265
+ beforeCreate?: (input: Record<string, unknown>, context: HookContext) => Promise<Record<string, unknown>> | Record<string, unknown>;
266
+ afterCreate?: (record: Record<string, unknown>, context: HookContext) => Promise<void> | void;
267
+ beforeUpdate?: (input: Record<string, unknown>, context: HookContext & {
268
+ id: string;
269
+ current: Record<string, unknown> | null;
270
+ }) => Promise<Record<string, unknown>> | Record<string, unknown>;
271
+ afterUpdate?: (record: Record<string, unknown>, context: HookContext & {
272
+ previous: Record<string, unknown> | null;
273
+ }) => Promise<void> | void;
274
+ /** Throw to refuse the delete. */
275
+ beforeDelete?: (context: HookContext & {
276
+ id: string;
277
+ current: Record<string, unknown> | null;
278
+ }) => Promise<void> | void;
279
+ afterDelete?: (context: HookContext & {
280
+ id: string;
281
+ }) => Promise<void> | void;
234
282
  }
235
283
  interface Resource<Fields extends Record<string, Field> = Record<string, Field>> {
236
284
  name: string;
@@ -239,6 +287,9 @@ interface Resource<Fields extends Record<string, Field> = Record<string, Field>>
239
287
  label: string;
240
288
  pluralLabel: string;
241
289
  icon: string | undefined;
290
+ group: string | undefined;
291
+ hooks: ResourceHooks;
292
+ computed: Record<string, (record: Record<string, unknown>) => unknown>;
242
293
  titleField: string;
243
294
  defaultSort: {
244
295
  field: string;
package/dist/server.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as drizzle_orm_sqlite_core from 'drizzle-orm/sqlite-core';
2
2
  import { BaseSQLiteDatabase, SQLiteTable } from 'drizzle-orm/sqlite-core';
3
- import { S as StoredField, R as Resource, F as Field, P as Policy } from './policy-BqRg9RVM.js';
3
+ import { S as StoredField, R as Resource, F as Field, P as Policy } from './policy-CvfE-wCK.js';
4
4
 
5
5
  interface ListQuery {
6
6
  page: number;
@@ -12,6 +12,19 @@ interface ListQuery {
12
12
  q?: string;
13
13
  /** Field key → coerced value; `null` means IS NULL. */
14
14
  filters: Record<string, string | number | boolean | null>;
15
+ /**
16
+ * Where to carry on from, instead of counting rows to skip. `OFFSET 500000` makes
17
+ * SQLite walk half a million index entries before it reads anything (8s at a million
18
+ * rows); a cursor turns that into a range scan (~100ms).
19
+ */
20
+ cursor?: Cursor;
21
+ }
22
+ /** The last row of the page you came from, and which way you're going. */
23
+ interface Cursor {
24
+ /** The sort field's value on that row. */
25
+ value: string | number | boolean | null;
26
+ id: string;
27
+ direction: "after" | "before";
15
28
  }
16
29
  interface QueryIssue {
17
30
  param: string;
@@ -58,8 +71,18 @@ interface ListResult<T = Record<string, unknown>> {
58
71
  meta: {
59
72
  page: number;
60
73
  perPage: number;
74
+ /** Rows matching the query, counted up to `COUNT_LIMIT`. */
61
75
  total: number;
62
76
  totalPages: number;
77
+ /**
78
+ * Whether `total` is the real number. Counting every row of a large table on every
79
+ * page load is the single most expensive thing a list does, so the count stops at
80
+ * `COUNT_LIMIT` and says so; past that, the list pages by cursor.
81
+ */
82
+ exactTotal: boolean;
83
+ /** Carry on from the end of this page, and from its start, without an offset. */
84
+ nextCursor?: string;
85
+ prevCursor?: string;
63
86
  };
64
87
  }
65
88
  interface ChangeEvent {
@@ -70,6 +93,19 @@ interface ChangeEvent {
70
93
  }
71
94
  interface ResourceStoreOptions {
72
95
  resource: Resource;
96
+ /**
97
+ * Who is making the write, for the descriptor's hooks. The dashboard and the API both
98
+ * know; a seed doesn't, and passes nothing.
99
+ */
100
+ currentUser?: () => Promise<{
101
+ id: string;
102
+ email: string;
103
+ role?: string | null;
104
+ } | null> | {
105
+ id: string;
106
+ email: string;
107
+ role?: string | null;
108
+ } | null;
73
109
  table: SQLiteTable;
74
110
  getDb: () => AnyDatabase;
75
111
  /**
@@ -89,7 +125,7 @@ interface ResourceStoreOptions {
89
125
  declare function createResourceStore(options: ResourceStoreOptions): {
90
126
  resource: Resource<Record<string, Field>>;
91
127
  table: SQLiteTable<drizzle_orm_sqlite_core.TableConfig>;
92
- /** `params`: page, perPage, sort, q, filter[field] (as parsed by parseListQuery). */
128
+ /** `params`: page, perPage, sort, q, filter[field], cursor (as parsed by parseListQuery). */
93
129
  list(params: URLSearchParams): Promise<Result<ListResult>>;
94
130
  get(id: string): Promise<Result<Record<string, unknown>>>;
95
131
  /** Title-field values for a set of ids (for showing relations). Missing ids are omitted. */
@@ -118,6 +154,8 @@ interface ResourceHandlerOptions {
118
154
  table: SQLiteTable;
119
155
  getDb: () => AnyDatabase;
120
156
  authorize: Authorize;
157
+ /** Who is making the request, for the descriptor's hooks. */
158
+ currentUser?: ResourceStoreOptions["currentUser"];
121
159
  /** Called after a write succeeds, for cache invalidation. */
122
160
  onChange?: (event: ChangeEvent) => void | Promise<void>;
123
161
  }
package/dist/server.js CHANGED
@@ -2,7 +2,7 @@ import {
2
2
  can,
3
3
  createValidators,
4
4
  storedFields
5
- } from "./chunk-434JXSDV.js";
5
+ } from "./chunk-PWP4QEZV.js";
6
6
 
7
7
  // ../../node_modules/.pnpm/drizzle-orm@0.45.2_@cloudfl_b372b29e6d5511672c7f9f9d2ac40572/node_modules/drizzle-orm/entity.js
8
8
  var entityKind = /* @__PURE__ */ Symbol.for("drizzle:entityKind");
@@ -1187,6 +1187,20 @@ function count(expression) {
1187
1187
  }
1188
1188
 
1189
1189
  // src/server/query.ts
1190
+ function encodeCursor(cursor) {
1191
+ const json2 = JSON.stringify([cursor.value, cursor.id, cursor.direction === "before" ? 0 : 1]);
1192
+ return btoa(json2).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
1193
+ }
1194
+ function decodeCursor(raw) {
1195
+ try {
1196
+ const json2 = atob(raw.replace(/-/g, "+").replace(/_/g, "/"));
1197
+ const parsed = JSON.parse(json2);
1198
+ if (!Array.isArray(parsed) || typeof parsed[1] !== "string") return void 0;
1199
+ return { value: parsed[0], id: parsed[1], direction: parsed[2] === 0 ? "before" : "after" };
1200
+ } catch {
1201
+ return void 0;
1202
+ }
1203
+ }
1190
1204
  var TIMESTAMP_FIELDS = ["createdAt", "updatedAt"];
1191
1205
  var MAX_PER_PAGE = 100;
1192
1206
  var MAX_SEARCH_LENGTH = 200;
@@ -1239,6 +1253,12 @@ function parseListQuery(resource, params) {
1239
1253
  else issues.push({ param: "sort", message: `can't sort by "${field}"` });
1240
1254
  }
1241
1255
  const q = params.get("q")?.trim().slice(0, MAX_SEARCH_LENGTH) || void 0;
1256
+ const rawCursor = params.get("cursor");
1257
+ let cursor;
1258
+ if (rawCursor) {
1259
+ cursor = decodeCursor(rawCursor);
1260
+ if (!cursor) issues.push({ param: "cursor", message: "isn't a cursor this list handed out" });
1261
+ }
1242
1262
  const filters = {};
1243
1263
  for (const [name, raw] of params) {
1244
1264
  const match = /^filter\[(.+)\]$/.exec(name);
@@ -1253,10 +1273,17 @@ function parseListQuery(resource, params) {
1253
1273
  if (value === void 0) issues.push({ param: name, message: `invalid value "${raw}"` });
1254
1274
  else filters[key] = value;
1255
1275
  }
1256
- return issues.length ? { issues } : { query: { page, perPage, sort, q, filters } };
1276
+ return issues.length ? { issues } : { query: { page, perPage, sort, q, filters, cursor } };
1257
1277
  }
1258
1278
 
1259
1279
  // src/server/store.ts
1280
+ var COUNT_LIMIT = 1e4;
1281
+ function toCursorValue(value) {
1282
+ if (value instanceof Date) return value.getTime();
1283
+ if (value === null || value === void 0) return null;
1284
+ if (typeof value === "object") return String(value);
1285
+ return value;
1286
+ }
1260
1287
  function errorText(error) {
1261
1288
  const parts = [];
1262
1289
  for (let current = error, depth = 0; current && depth < 5; depth++) {
@@ -1322,14 +1349,29 @@ function createResourceStore(options) {
1322
1349
  const invalid = (issues) => fail(422, "Validation failed.", { issues: issues.map((issue) => ({ path: issue.path.map(String).join("."), message: issue.message })) });
1323
1350
  const byId = (id) => eq(idColumn, id);
1324
1351
  const notFound = () => fail(404, `${resource.label} not found.`);
1352
+ const computed = Object.entries(resource.computed ?? {});
1353
+ const withComputed = (row) => {
1354
+ if (computed.length === 0) return row;
1355
+ const result = { ...row };
1356
+ for (const [key, compute] of computed) {
1357
+ try {
1358
+ result[key] = compute(row);
1359
+ } catch {
1360
+ result[key] = null;
1361
+ }
1362
+ }
1363
+ return result;
1364
+ };
1365
+ const hooks = resource.hooks ?? {};
1366
+ const hookContext = async () => ({ db: getDb(), user: await options.currentUser?.() ?? null });
1325
1367
  return {
1326
1368
  resource,
1327
1369
  table,
1328
- /** `params`: page, perPage, sort, q, filter[field] (as parsed by parseListQuery). */
1370
+ /** `params`: page, perPage, sort, q, filter[field], cursor (as parsed by parseListQuery). */
1329
1371
  async list(params) {
1330
1372
  const parsed = parseListQuery(resource, params);
1331
1373
  if ("issues" in parsed) return fail(400, "Invalid query.", { queryIssues: parsed.issues });
1332
- const { page, perPage, sort, q, filters } = parsed.query;
1374
+ const { page, perPage, sort, q, filters, cursor } = parsed.query;
1333
1375
  const conditions = [];
1334
1376
  if (q) {
1335
1377
  const pattern = `%${q.replace(/[\\%_]/g, (char) => `\\${char}`)}%`;
@@ -1339,22 +1381,56 @@ function createResourceStore(options) {
1339
1381
  for (const [key, value] of Object.entries(filters)) {
1340
1382
  conditions.push(value === null ? isNull(columns[key]) : eq(columns[key], value));
1341
1383
  }
1342
- const where = conditions.length ? and(...conditions) : void 0;
1343
- const order = sort.direction === "asc" ? asc(columns[sort.field]) : desc(columns[sort.field]);
1384
+ const filterWhere = conditions.length ? and(...conditions) : void 0;
1385
+ const sortColumn = columns[sort.field];
1386
+ const backwards = cursor?.direction === "before";
1387
+ const descending = backwards ? sort.direction === "asc" : sort.direction === "desc";
1388
+ const order = descending ? [desc(sortColumn), desc(idColumn)] : [asc(sortColumn), asc(idColumn)];
1389
+ const pageConditions = [...conditions];
1390
+ if (cursor) {
1391
+ const value = cursor.value;
1392
+ pageConditions.push(
1393
+ descending ? or(sql`${sortColumn} < ${value}`, and(sql`${sortColumn} = ${value}`, sql`${idColumn} < ${cursor.id}`)) : or(sql`${sortColumn} > ${value}`, and(sql`${sortColumn} = ${value}`, sql`${idColumn} > ${cursor.id}`))
1394
+ );
1395
+ }
1396
+ const where = pageConditions.length ? and(...pageConditions) : void 0;
1344
1397
  const db = getDb();
1345
- const [rows, totals] = await Promise.all([
1346
- db.select().from(table).where(where).orderBy(order, asc(idColumn)).limit(perPage).offset((page - 1) * perPage),
1347
- db.select({ total: count() }).from(table).where(where)
1398
+ const query = db.select().from(table).where(where).orderBy(...order).limit(perPage + 1);
1399
+ const [found, counted] = await Promise.all([
1400
+ cursor ? query : query.offset((page - 1) * perPage),
1401
+ // One row past the limit is enough to know there's more without counting it all.
1402
+ db.select({ total: count() }).from(db.select({ id: idColumn }).from(table).where(filterWhere).limit(COUNT_LIMIT + 1).as("capped"))
1348
1403
  ]);
1349
- const total = totals[0]?.total ?? 0;
1404
+ const hasMore = found.length > perPage;
1405
+ const rows = (hasMore ? found.slice(0, perPage) : found).map(withComputed);
1406
+ if (backwards) rows.reverse();
1407
+ const counting = counted[0]?.total ?? 0;
1408
+ const exactTotal = counting <= COUNT_LIMIT;
1409
+ const total = exactTotal ? counting : COUNT_LIMIT;
1410
+ const first = rows[0];
1411
+ const last = rows[rows.length - 1];
1412
+ const moreAfter = backwards ? true : hasMore;
1413
+ const moreBefore = backwards ? hasMore : Boolean(cursor) || page > 1;
1414
+ const cursorFor = (row, direction) => row ? encodeCursor({ value: toCursorValue(row[sort.field]), id: String(row.id), direction }) : void 0;
1350
1415
  return {
1351
1416
  ok: true,
1352
- data: { data: rows, meta: { page, perPage, total, totalPages: Math.max(1, Math.ceil(total / perPage)) } }
1417
+ data: {
1418
+ data: rows,
1419
+ meta: {
1420
+ page,
1421
+ perPage,
1422
+ total,
1423
+ totalPages: Math.max(1, Math.ceil(total / perPage)),
1424
+ exactTotal,
1425
+ nextCursor: moreAfter ? cursorFor(last, "after") : void 0,
1426
+ prevCursor: moreBefore ? cursorFor(first, "before") : void 0
1427
+ }
1428
+ }
1353
1429
  };
1354
1430
  },
1355
1431
  async get(id) {
1356
1432
  const [record] = await getDb().select().from(table).where(byId(id)).limit(1);
1357
- return record ? { ok: true, data: record } : notFound();
1433
+ return record ? { ok: true, data: withComputed(record) } : notFound();
1358
1434
  },
1359
1435
  /** Title-field values for a set of ids (for showing relations). Missing ids are omitted. */
1360
1436
  async titles(ids) {
@@ -1368,9 +1444,13 @@ function createResourceStore(options) {
1368
1444
  const result = validators.create.safeParse(input);
1369
1445
  if (!result.success) return Promise.resolve(invalid(result.error.issues));
1370
1446
  return writing("create", async () => {
1447
+ const context = await hookContext();
1448
+ const input2 = hooks.beforeCreate ? await hooks.beforeCreate({ ...result.data }, context) : result.data;
1371
1449
  const now = /* @__PURE__ */ new Date();
1372
- const [record] = await getDb().insert(table).values({ ...result.data, id: crypto.randomUUID(), createdAt: now, updatedAt: now }).returning();
1373
- return { ok: true, data: record };
1450
+ const [record] = await getDb().insert(table).values({ ...input2, id: crypto.randomUUID(), createdAt: now, updatedAt: now }).returning();
1451
+ const saved = withComputed(record);
1452
+ await hooks.afterCreate?.(saved, context);
1453
+ return { ok: true, data: saved };
1374
1454
  });
1375
1455
  },
1376
1456
  /** Partial update (PATCH semantics). */
@@ -1378,8 +1458,15 @@ function createResourceStore(options) {
1378
1458
  const result = validators.update.safeParse(input);
1379
1459
  if (!result.success) return Promise.resolve(invalid(result.error.issues));
1380
1460
  return writing("update", async () => {
1381
- const [record] = await getDb().update(table).set({ ...result.data, updatedAt: /* @__PURE__ */ new Date() }).where(byId(id)).returning();
1382
- return record ? { ok: true, data: record } : notFound();
1461
+ const context = await hookContext();
1462
+ const [existing] = hooks.beforeUpdate || hooks.afterUpdate ? await getDb().select().from(table).where(byId(id)).limit(1) : [];
1463
+ const current = existing ?? null;
1464
+ const input2 = hooks.beforeUpdate ? await hooks.beforeUpdate({ ...result.data }, { ...context, id, current }) : result.data;
1465
+ const [record] = await getDb().update(table).set({ ...input2, updatedAt: /* @__PURE__ */ new Date() }).where(byId(id)).returning();
1466
+ if (!record) return notFound();
1467
+ const saved = withComputed(record);
1468
+ await hooks.afterUpdate?.(saved, { ...context, previous: current });
1469
+ return { ok: true, data: saved };
1383
1470
  });
1384
1471
  },
1385
1472
  /** Full replacement (PUT semantics): omitted fields go back to their default, or null when optional. */
@@ -1393,13 +1480,20 @@ function createResourceStore(options) {
1393
1480
  }
1394
1481
  Object.assign(values, result.data, { updatedAt: /* @__PURE__ */ new Date() });
1395
1482
  const [record] = await getDb().update(table).set(values).where(byId(id)).returning();
1396
- return record ? { ok: true, data: record } : notFound();
1483
+ return record ? { ok: true, data: withComputed(record) } : notFound();
1397
1484
  });
1398
1485
  },
1399
1486
  delete(id) {
1400
1487
  return writing("delete", async () => {
1488
+ const context = await hookContext();
1489
+ if (hooks.beforeDelete) {
1490
+ const [existing] = await getDb().select().from(table).where(byId(id)).limit(1);
1491
+ await hooks.beforeDelete({ ...context, id, current: existing ?? null });
1492
+ }
1401
1493
  const deleted = await getDb().delete(table).where(byId(id)).returning({ id: idColumn });
1402
- return deleted.length ? { ok: true, data: { id } } : notFound();
1494
+ if (deleted.length === 0) return notFound();
1495
+ await hooks.afterDelete?.({ ...context, id });
1496
+ return { ok: true, data: { id } };
1403
1497
  });
1404
1498
  }
1405
1499
  };
@@ -1625,8 +1719,16 @@ function openApiDocument(options) {
1625
1719
  },
1626
1720
  PageMeta: {
1627
1721
  type: "object",
1628
- properties: { page: { type: "integer" }, perPage: { type: "integer" }, total: { type: "integer" }, totalPages: { type: "integer" } },
1629
- required: ["page", "perPage", "total", "totalPages"]
1722
+ properties: {
1723
+ page: { type: "integer" },
1724
+ perPage: { type: "integer" },
1725
+ total: { type: "integer", description: "Matching rows, counted up to 10,000." },
1726
+ totalPages: { type: "integer" },
1727
+ exactTotal: { type: "boolean", description: "False when there are more than 10,000 matches; page with the cursors instead." },
1728
+ nextCursor: { type: "string", description: "Pass as ?cursor= for the next page." },
1729
+ prevCursor: { type: "string" }
1730
+ },
1731
+ required: ["page", "perPage", "total", "totalPages", "exactTotal"]
1630
1732
  }
1631
1733
  };
1632
1734
  const tags = [{ name: "Authentication", description: "Sign in here first: the session cookie authorizes every other request." }];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flaredev/core",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Flare runtime: resource descriptors, field grammar, and Cloudflare-native helpers",
5
5
  "license": "MIT",
6
6
  "author": "Muke Johnbaptist",