@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.
- package/dist/{chunk-434JXSDV.js → chunk-PWP4QEZV.js} +3 -0
- package/dist/index.d.ts +79 -5
- package/dist/index.js +292 -2
- package/dist/{policy-BqRg9RVM.d.ts → policy-CvfE-wCK.d.ts} +51 -0
- package/dist/server.d.ts +40 -2
- package/dist/server.js +122 -20
- package/package.json +1 -1
|
@@ -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-
|
|
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-
|
|
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.
|
|
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-
|
|
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.
|
|
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-
|
|
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-
|
|
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
|
|
1343
|
-
const
|
|
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
|
|
1346
|
-
|
|
1347
|
-
|
|
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
|
|
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: {
|
|
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({ ...
|
|
1373
|
-
|
|
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
|
|
1382
|
-
|
|
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
|
-
|
|
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: {
|
|
1629
|
-
|
|
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." }];
|