@flaredev/core 0.3.0 → 0.4.1

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.
@@ -1,6 +1,9 @@
1
1
  // src/resource/fields.ts
2
2
  var STRING_FORMATS = ["email", "url", "tel", "domain", "country", "color", "slug"];
3
3
  var FILE_CATEGORIES = {
4
+ // For a field that holds whatever someone has: a drive, an attachment, a backup.
5
+ // Nothing is checked beyond the size limit, so don't reach for it out of convenience.
6
+ any: ["*/*"],
4
7
  image: ["image/png", "image/jpeg", "image/gif", "image/webp", "image/avif"],
5
8
  pdf: ["application/pdf"],
6
9
  video: ["video/*"],
@@ -17,7 +20,18 @@ var FILE_CATEGORIES = {
17
20
  "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
18
21
  "application/vnd.oasis.opendocument.spreadsheet"
19
22
  ],
20
- archive: ["application/zip"]
23
+ // Browsers disagree about what a .zip is — Windows sends x-zip-compressed — and an
24
+ // archive field that refuses an ordinary zip is no use, so the aliases are listed too.
25
+ archive: [
26
+ "application/zip",
27
+ "application/x-zip-compressed",
28
+ "application/x-zip",
29
+ "application/gzip",
30
+ "application/x-gzip",
31
+ "application/x-7z-compressed",
32
+ "application/vnd.rar",
33
+ "application/x-rar-compressed"
34
+ ]
21
35
  };
22
36
  function mimeTypesFor(accept) {
23
37
  return [...new Set(accept.flatMap((category) => FILE_CATEGORIES[category]))];
@@ -87,6 +101,13 @@ function pluralize(value) {
87
101
 
88
102
  // src/resource/define.ts
89
103
  var RESERVED_FIELD_NAMES = ["id", "createdAt", "updatedAt"];
104
+ function clientResource(resource) {
105
+ const { hooks, computed, $types, ...rest } = resource;
106
+ void hooks;
107
+ void computed;
108
+ void $types;
109
+ return rest;
110
+ }
90
111
  var ResourceDefinitionError = class extends Error {
91
112
  constructor(resource, message) {
92
113
  super(`Resource "${resource}": ${message}`);
@@ -152,6 +173,9 @@ function defineResource(config) {
152
173
  label: config.label ?? humanize(name),
153
174
  pluralLabel: config.pluralLabel ?? humanize(pluralName),
154
175
  icon: config.icon,
176
+ group: config.group,
177
+ hooks: config.hooks ?? {},
178
+ computed: config.computed ?? {},
155
179
  titleField,
156
180
  defaultSort,
157
181
  perPage,
@@ -382,6 +406,7 @@ export {
382
406
  humanize,
383
407
  pluralize,
384
408
  RESERVED_FIELD_NAMES,
409
+ clientResource,
385
410
  ResourceDefinitionError,
386
411
  defaultFieldLabel,
387
412
  defineResource,
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-DaKcWZ5g.js';
2
+ export { B as BelongsToField, a as BooleanField, b as ClientResource, D as DEFAULT_FILE_MAX_BYTES, c as DateField, d as DateTimeField, E as EnumField, e as FILE_CATEGORIES, f as FieldKind, g as FieldValue, h as FileCategory, i as FileField, j as FloatField, H as HasManyField, I as IntField, M as MultiSelectField, P as Policy, k as PolicyAction, l as PolicyConfig, m as PolicyError, n as RESERVED_FIELD_NAMES, o as RecordOf, p as ResourceConfig, q as ResourceDefinitionError, r as STRING_FORMATS, s as StringField, t as StringFormat, T as TextField, u as allowedActions, v as can, w as clientResource, x as columnName, y as defaultFieldLabel, z as definePolicy, A as defineResource, G as field, J as fileMaxBytes, K as mimeTypesFor, L as storedFields } from './policy-DaKcWZ5g.js';
3
3
  import { z } from 'zod';
4
4
 
5
5
  /**
@@ -286,7 +286,7 @@ declare function fieldSchema(def: StoredField): z.ZodType;
286
286
  * edit a record could attach another resource's private file and read it through theirs.
287
287
  */
288
288
  declare const fileKeyPrefix: (resource: Pick<Resource, "table">, fieldKey: string) => string;
289
- declare function createValidators<Fields extends Record<string, Field>>(resource: Resource<Fields>): ResourceValidators<Fields>;
289
+ declare function createValidators<Fields extends Record<string, Field>>(resource: Pick<Resource<Fields>, "fields" | "table">): ResourceValidators<Fields>;
290
290
 
291
291
  /** Naming helpers shared by descriptors and the generator. */
292
292
  /** "OrderItem" / "order item" / "order-item" → ["order", "item"] */
@@ -365,13 +365,13 @@ declare function formatValue(def: StoredField | {
365
365
  */
366
366
  type FormValues = Record<string, string | boolean>;
367
367
  /** Initial form state: the record's values (edit) or the descriptor's defaults (create). */
368
- declare function initialFormValues(resource: Resource, record?: Record<string, unknown> | null): FormValues;
368
+ declare function initialFormValues(resource: Pick<Resource, "fields">, record?: Record<string, unknown> | null): FormValues;
369
369
  /**
370
370
  * API input from form state. Empty optional fields become null (clearing them);
371
371
  * empty fields that have a default are omitted on create so the default applies;
372
372
  * empty required fields are left for validation to report as "Required".
373
373
  */
374
- declare function formValuesToInput(resource: Resource, values: FormValues, mode: "create" | "edit"): Record<string, unknown>;
374
+ declare function formValuesToInput(resource: Pick<Resource, "fields">, values: FormValues, mode: "create" | "edit"): Record<string, unknown>;
375
375
  /** A multiselect's form state (a JSON array string) as the picked values. */
376
376
  declare function parseMultiValue(raw: string | boolean | undefined): string[];
377
377
  /** Field errors keyed by field name, from validation or API issues (first message wins). */
@@ -439,6 +439,8 @@ interface Fake {
439
439
  email(name?: string): string;
440
440
  company(): string;
441
441
  jobTitle(): string;
442
+ /** "Brushed steel desk lamp", or a digital one: "Starter kit licence". */
443
+ product(digital?: boolean): string;
442
444
  city(): string;
443
445
  /** ISO 3166-1 alpha-2, e.g. "UG". */
444
446
  country(): string;
@@ -507,6 +509,6 @@ interface SeedContext {
507
509
  type Seed = (context: SeedContext) => void | Promise<void>;
508
510
  declare function defineSeed(seed: Seed): Seed;
509
511
 
510
- declare const FLARE_VERSION = "0.3.0";
512
+ declare const FLARE_VERSION = "0.4.1";
511
513
 
512
514
  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
@@ -8,6 +8,7 @@ import {
8
8
  allowedActions,
9
9
  camelCase,
10
10
  can,
11
+ clientResource,
11
12
  columnName,
12
13
  countries,
13
14
  countryName,
@@ -38,7 +39,7 @@ import {
38
39
  storedFields,
39
40
  toE164,
40
41
  words
41
- } from "./chunk-434JXSDV.js";
42
+ } from "./chunk-VLHPSNQN.js";
42
43
 
43
44
  // src/password.ts
44
45
  var ALGORITHM = "pbkdf2-sha256";
@@ -241,6 +242,8 @@ var EMPTY_ZIP = [80, 75, 5, 6];
241
242
  var OLE2 = [208, 207, 17, 224, 161, 177, 26, 225];
242
243
  var EBML = [26, 69, 223, 163];
243
244
  var isZip = (head) => startsWith(head, ZIP) || startsWith(head, EMPTY_ZIP);
245
+ var isGzip = (head) => startsWith(head, [31, 139]);
246
+ var isRar = (head) => startsWith(head, ascii("Rar!"));
244
247
  var EXACT = {
245
248
  "image/png": (head) => startsWith(head, [137, 80, 78, 71, 13, 10, 26, 10]),
246
249
  "image/jpeg": (head) => startsWith(head, [255, 216, 255]),
@@ -249,6 +252,13 @@ var EXACT = {
249
252
  "image/avif": (head) => ["avif", "avis", "mif1", "msf1"].includes(isoBrand(head) ?? ""),
250
253
  "application/pdf": (head) => startsWith(head, ascii("%PDF-")),
251
254
  "application/zip": isZip,
255
+ "application/x-zip-compressed": isZip,
256
+ "application/x-zip": isZip,
257
+ "application/gzip": isGzip,
258
+ "application/x-gzip": isGzip,
259
+ "application/x-7z-compressed": (head) => startsWith(head, [55, 122, 188, 175, 39, 28]),
260
+ "application/vnd.rar": isRar,
261
+ "application/x-rar-compressed": isRar,
252
262
  // Office Open XML and OpenDocument files are zip archives.
253
263
  "application/vnd.openxmlformats-officedocument.wordprocessingml.document": isZip,
254
264
  "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": isZip,
@@ -281,6 +291,7 @@ function matchesContentType(contentType, allowed) {
281
291
  if (!/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/.test(type)) return false;
282
292
  return allowed.some((pattern) => {
283
293
  const p = pattern.trim().toLowerCase();
294
+ if (p === "*/*") return true;
284
295
  return p.endsWith("/*") ? type.startsWith(p.slice(0, -1)) : type === p;
285
296
  });
286
297
  }
@@ -873,6 +884,26 @@ var WORDS = [
873
884
  "variance",
874
885
  "workflow"
875
886
  ];
887
+ var PRODUCT_QUALITIES = ["Oak", "Walnut", "Brushed steel", "Canvas", "Merino", "Recycled", "Matte black", "Linen", "Copper", "Frosted glass"];
888
+ var PRODUCT_THINGS = [
889
+ "desk lamp",
890
+ "office chair",
891
+ "notebook",
892
+ "water bottle",
893
+ "backpack",
894
+ "keyboard",
895
+ "mouse mat",
896
+ "coffee grinder",
897
+ "travel mug",
898
+ "wall clock",
899
+ "storage box",
900
+ "cable tidy",
901
+ "monitor stand",
902
+ "planter",
903
+ "door mat",
904
+ "tote bag"
905
+ ];
906
+ var DIGITAL_THINGS = ["starter kit", "source code licence", "icon pack", "template bundle", "course", "font family", "preset pack", "e-book"];
876
907
  var DOMAINS = ["example.com", "example.org", "example.net", "mail.example.com"];
877
908
  var TLDS = ["com", "io", "co", "org", "net"];
878
909
  var COLORS = ["#f2541d", "#2563eb", "#16a34a", "#d97706", "#7c3aed", "#0891b2", "#db2777", "#4b5563"];
@@ -924,6 +955,7 @@ function createFake(seed = 20260101) {
924
955
  return `${slugify2(local).replace(/-/g, ".")}${step()}@${pick(DOMAINS)}`;
925
956
  },
926
957
  company: () => `${pick(COMPANY_HEADS)} ${pick(COMPANY_TAILS)}`,
958
+ product: (digital = false) => digital ? `${pick(PRODUCT_QUALITIES)} ${pick(DIGITAL_THINGS)}` : `${pick(PRODUCT_QUALITIES)} ${pick(PRODUCT_THINGS)}`,
927
959
  jobTitle: () => `${pick(["Head of", "Senior", "Lead", "Regional", "Junior"])} ${pick(["Operations", "Sales", "Support", "Engineering", "Finance", "Logistics"])}`,
928
960
  city: () => pick(CITIES),
929
961
  country: () => pick(COUNTRIES)[0],
@@ -962,7 +994,7 @@ function createFake(seed = 20260101) {
962
994
  var fake = createFake();
963
995
 
964
996
  // src/index.ts
965
- var FLARE_VERSION = "0.3.0";
997
+ var FLARE_VERSION = "0.4.1";
966
998
  export {
967
999
  DEFAULT_FILE_MAX_BYTES,
968
1000
  FILE_CATEGORIES,
@@ -979,6 +1011,7 @@ export {
979
1011
  camelCase,
980
1012
  can,
981
1013
  checkoutPaid,
1014
+ clientResource,
982
1015
  columnName,
983
1016
  countries,
984
1017
  countryName,
@@ -116,6 +116,7 @@ type Field = StringField | TextField | IntField | FloatField | BooleanField | Da
116
116
  type StoredField = Exclude<Field, HasManyField>;
117
117
  /** File categories usable in `file:[...]` and the MIME types each allows. */
118
118
  declare const FILE_CATEGORIES: {
119
+ readonly any: readonly ["*/*"];
119
120
  readonly image: readonly ["image/png", "image/jpeg", "image/gif", "image/webp", "image/avif"];
120
121
  readonly pdf: readonly ["application/pdf"];
121
122
  readonly video: readonly ["video/*"];
@@ -124,7 +125,7 @@ declare const FILE_CATEGORIES: {
124
125
  readonly csv: readonly ["text/csv"];
125
126
  readonly document: readonly ["application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/vnd.oasis.opendocument.text"];
126
127
  readonly spreadsheet: readonly ["application/vnd.ms-excel", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/vnd.oasis.opendocument.spreadsheet"];
127
- readonly archive: readonly ["application/zip"];
128
+ readonly archive: readonly ["application/zip", "application/x-zip-compressed", "application/x-zip", "application/gzip", "application/x-gzip", "application/x-7z-compressed", "application/vnd.rar", "application/x-rar-compressed"];
128
129
  };
129
130
  type FileCategory = keyof typeof FILE_CATEGORIES;
130
131
  declare function mimeTypesFor(accept: readonly FileCategory[]): string[];
@@ -223,6 +224,11 @@ interface ResourceConfig<Fields extends Record<string, Field>> {
223
224
  pluralLabel?: string;
224
225
  /** lucide-react icon name for navigation, e.g. "users". Default: a generic document icon. */
225
226
  icon?: string;
227
+ /**
228
+ * Heading this resource sits under in the dashboard sidebar, e.g. "Sales". Resources
229
+ * without one share a "Resources" group, which is all a small app needs.
230
+ */
231
+ group?: string;
226
232
  /** Field used to represent a record in lists and relation pickers. Default: first string field. */
227
233
  titleField?: FieldKey<Fields>;
228
234
  defaultSort?: {
@@ -231,6 +237,53 @@ interface ResourceConfig<Fields extends Record<string, Field>> {
231
237
  };
232
238
  /** Page size for list endpoints and tables. Default 25. */
233
239
  perPage?: number;
240
+ /**
241
+ * Your logic, beside the fields it belongs to. Every write — the REST API, the
242
+ * dashboard's forms, a seed, an import — goes through the same store, so a hook here
243
+ * runs for all of them, and there's one place to read to know what happens on a write.
244
+ */
245
+ hooks?: ResourceHooks;
246
+ /**
247
+ * Values worked out from the record rather than stored: a total from a quantity and a
248
+ * price, a full name from two columns. They're added to every record the API and the
249
+ * dashboard return, and there's no column and no migration behind them.
250
+ */
251
+ computed?: Record<string, (record: Record<string, unknown>) => unknown>;
252
+ }
253
+ /** What a hook is handed: the request's own database and who is making it. */
254
+ interface HookContext {
255
+ /** Drizzle, already bound to this request. */
256
+ db: any;
257
+ /** The signed-in user, when the write came from somewhere that has one. */
258
+ user?: {
259
+ id: string;
260
+ email: string;
261
+ role?: string | null;
262
+ } | null;
263
+ }
264
+ interface ResourceHooks {
265
+ /**
266
+ * Change or check the input before it's written. Return the input (changed or not), or
267
+ * throw to refuse. It runs *before* validation, so it can fill in a field the caller
268
+ * couldn't supply — an order number, a slug — and what it returns is then validated.
269
+ */
270
+ beforeCreate?: (input: Record<string, unknown>, context: HookContext) => Promise<Record<string, unknown>> | Record<string, unknown>;
271
+ afterCreate?: (record: Record<string, unknown>, context: HookContext) => Promise<void> | void;
272
+ beforeUpdate?: (input: Record<string, unknown>, context: HookContext & {
273
+ id: string;
274
+ current: Record<string, unknown> | null;
275
+ }) => Promise<Record<string, unknown>> | Record<string, unknown>;
276
+ afterUpdate?: (record: Record<string, unknown>, context: HookContext & {
277
+ previous: Record<string, unknown> | null;
278
+ }) => Promise<void> | void;
279
+ /** Throw to refuse the delete. */
280
+ beforeDelete?: (context: HookContext & {
281
+ id: string;
282
+ current: Record<string, unknown> | null;
283
+ }) => Promise<void> | void;
284
+ afterDelete?: (context: HookContext & {
285
+ id: string;
286
+ }) => Promise<void> | void;
234
287
  }
235
288
  interface Resource<Fields extends Record<string, Field> = Record<string, Field>> {
236
289
  name: string;
@@ -239,6 +292,9 @@ interface Resource<Fields extends Record<string, Field> = Record<string, Field>>
239
292
  label: string;
240
293
  pluralLabel: string;
241
294
  icon: string | undefined;
295
+ group: string | undefined;
296
+ hooks: ResourceHooks;
297
+ computed: Record<string, (record: Record<string, unknown>) => unknown>;
242
298
  titleField: string;
243
299
  defaultSort: {
244
300
  field: string;
@@ -256,6 +312,22 @@ interface Resource<Fields extends Record<string, Field> = Record<string, Field>>
256
312
  record: RecordOf<Fields>;
257
313
  };
258
314
  }
315
+ /**
316
+ * A resource as a client component can receive it: everything that describes the fields,
317
+ * without the code.
318
+ *
319
+ * `hooks` and `computed` hold functions, and a function can't cross from the server to
320
+ * the browser — React refuses a prop it wasn't told to expose, and the page fails to
321
+ * render. So a server component hands a client one `clientResource(resource)` instead.
322
+ * The `never` types below turn passing the whole resource into a type error, which is
323
+ * cheaper to find than the runtime one.
324
+ */
325
+ type ClientResource<Fields extends Record<string, Field> = Record<string, Field>> = Omit<Resource<Fields>, "hooks" | "computed" | "$types"> & {
326
+ hooks?: never;
327
+ computed?: never;
328
+ };
329
+ /** The parts of a resource that are safe to send to the browser. */
330
+ declare function clientResource<Fields extends Record<string, Field>>(resource: Resource<Fields>): ClientResource<Fields>;
259
331
  declare class ResourceDefinitionError extends Error {
260
332
  constructor(resource: string, message: string);
261
333
  }
@@ -263,7 +335,7 @@ declare class ResourceDefinitionError extends Error {
263
335
  declare function defaultFieldLabel(key: string, field: Field): string;
264
336
  declare function defineResource<const Fields extends Record<string, Field>>(config: ResourceConfig<Fields>): Resource<Fields>;
265
337
  /** Stored (column-backed) fields of a resource, in declaration order. */
266
- declare function storedFields(resource: Resource): [string, StoredField & {
338
+ declare function storedFields(resource: Pick<Resource, "fields">): [string, StoredField & {
267
339
  label: string;
268
340
  }][];
269
341
  /** Database column name for a field key ("companyId" → "company_id"). */
@@ -305,4 +377,4 @@ declare function can(policy: Policy | undefined | null, role: string | null | un
305
377
  /** Actions `role` may perform, for hiding UI it can't use. */
306
378
  declare function allowedActions(policy: Policy | undefined | null, role: string | null | undefined): Record<Exclude<PolicyAction, "list">, boolean>;
307
379
 
308
- export { fileMaxBytes as A, type BelongsToField as B, type CreateInput as C, DEFAULT_FILE_MAX_BYTES as D, type EnumField as E, type Field as F, mimeTypesFor as G, type HasManyField as H, type IntField as I, storedFields as J, type MultiSelectField as M, type Policy as P, type Resource as R, type StoredField as S, type TextField as T, type UpdateInput as U, type BooleanField as a, type DateField as b, type DateTimeField as c, FILE_CATEGORIES as d, type FieldKind as e, type FieldValue as f, type FileCategory as g, type FileField as h, type FloatField as i, type PolicyAction as j, type PolicyConfig as k, PolicyError as l, RESERVED_FIELD_NAMES as m, type RecordOf as n, type ResourceConfig as o, ResourceDefinitionError as p, STRING_FORMATS as q, type StringField as r, type StringFormat as s, allowedActions as t, can as u, columnName as v, defaultFieldLabel as w, definePolicy as x, defineResource as y, field as z };
380
+ export { defineResource as A, type BelongsToField as B, type CreateInput as C, DEFAULT_FILE_MAX_BYTES as D, type EnumField as E, type Field as F, field as G, type HasManyField as H, type IntField as I, fileMaxBytes as J, mimeTypesFor as K, storedFields as L, type MultiSelectField as M, type Policy as P, type Resource as R, type StoredField as S, type TextField as T, type UpdateInput as U, type BooleanField as a, type ClientResource as b, type DateField as c, type DateTimeField as d, FILE_CATEGORIES as e, type FieldKind as f, type FieldValue as g, type FileCategory as h, type FileField as i, type FloatField as j, type PolicyAction as k, type PolicyConfig as l, PolicyError as m, RESERVED_FIELD_NAMES as n, type RecordOf as o, type ResourceConfig as p, ResourceDefinitionError as q, STRING_FORMATS as r, type StringField as s, type StringFormat as t, allowedActions as u, can as v, clientResource as w, columnName as x, defaultFieldLabel as y, definePolicy as z };
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-DaKcWZ5g.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,14 +125,14 @@ 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. */
96
132
  titles(ids: string[]): Promise<Record<string, string>>;
97
- create(input: unknown): Promise<Result<Record<string, unknown>>>;
133
+ create(raw: unknown): Promise<Result<Record<string, unknown>>>;
98
134
  /** Partial update (PATCH semantics). */
99
- update(id: string, input: unknown): Promise<Result<Record<string, unknown>>>;
135
+ update(id: string, raw: unknown): Promise<Result<Record<string, unknown>>>;
100
136
  /** Full replacement (PUT semantics): omitted fields go back to their default, or null when optional. */
101
137
  replace(id: string, input: unknown): Promise<Result<Record<string, unknown>>>;
102
138
  delete(id: string): Promise<Result<{
@@ -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
  }
@@ -144,8 +182,8 @@ declare function createResourceHandlers(options: ResourceHandlerOptions): {
144
182
  list(params: URLSearchParams): Promise<Result<ListResult>>;
145
183
  get(id: string): Promise<Result<Record<string, unknown>>>;
146
184
  titles(ids: string[]): Promise<Record<string, string>>;
147
- create(input: unknown): Promise<Result<Record<string, unknown>>>;
148
- update(id: string, input: unknown): Promise<Result<Record<string, unknown>>>;
185
+ create(raw: unknown): Promise<Result<Record<string, unknown>>>;
186
+ update(id: string, raw: unknown): Promise<Result<Record<string, unknown>>>;
149
187
  replace(id: string, input: unknown): Promise<Result<Record<string, unknown>>>;
150
188
  delete(id: string): Promise<Result<{
151
189
  id: string;
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-VLHPSNQN.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) {
@@ -1364,22 +1440,35 @@ function createResourceStore(options) {
1364
1440
  const rows = await getDb().select({ id: idColumn, title: titleColumn }).from(table).where(inArray(idColumn, unique));
1365
1441
  return Object.fromEntries(rows.map((row) => [row.id, row.title == null ? row.id : String(row.title)]));
1366
1442
  },
1367
- create(input) {
1368
- const result = validators.create.safeParse(input);
1369
- if (!result.success) return Promise.resolve(invalid(result.error.issues));
1443
+ create(raw) {
1370
1444
  return writing("create", async () => {
1445
+ const context = await hookContext();
1446
+ const supplied = hooks.beforeCreate ? await hooks.beforeCreate({ ...raw ?? {} }, context) : raw;
1447
+ const result = validators.create.safeParse(supplied);
1448
+ if (!result.success) return invalid(result.error.issues);
1449
+ const input = result.data;
1371
1450
  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 };
1451
+ const [record] = await getDb().insert(table).values({ ...input, id: crypto.randomUUID(), createdAt: now, updatedAt: now }).returning();
1452
+ const saved = withComputed(record);
1453
+ await hooks.afterCreate?.(saved, context);
1454
+ return { ok: true, data: saved };
1374
1455
  });
1375
1456
  },
1376
1457
  /** Partial update (PATCH semantics). */
1377
- update(id, input) {
1378
- const result = validators.update.safeParse(input);
1379
- if (!result.success) return Promise.resolve(invalid(result.error.issues));
1458
+ update(id, raw) {
1380
1459
  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();
1460
+ const context = await hookContext();
1461
+ const [existing] = hooks.beforeUpdate || hooks.afterUpdate ? await getDb().select().from(table).where(byId(id)).limit(1) : [];
1462
+ const current = existing ?? null;
1463
+ const supplied = hooks.beforeUpdate ? await hooks.beforeUpdate({ ...raw ?? {} }, { ...context, id, current }) : raw;
1464
+ const result = validators.update.safeParse(supplied);
1465
+ if (!result.success) return invalid(result.error.issues);
1466
+ const input = result.data;
1467
+ const [record] = await getDb().update(table).set({ ...input, updatedAt: /* @__PURE__ */ new Date() }).where(byId(id)).returning();
1468
+ if (!record) return notFound();
1469
+ const saved = withComputed(record);
1470
+ await hooks.afterUpdate?.(saved, { ...context, previous: current });
1471
+ return { ok: true, data: saved };
1383
1472
  });
1384
1473
  },
1385
1474
  /** Full replacement (PUT semantics): omitted fields go back to their default, or null when optional. */
@@ -1393,13 +1482,20 @@ function createResourceStore(options) {
1393
1482
  }
1394
1483
  Object.assign(values, result.data, { updatedAt: /* @__PURE__ */ new Date() });
1395
1484
  const [record] = await getDb().update(table).set(values).where(byId(id)).returning();
1396
- return record ? { ok: true, data: record } : notFound();
1485
+ return record ? { ok: true, data: withComputed(record) } : notFound();
1397
1486
  });
1398
1487
  },
1399
1488
  delete(id) {
1400
1489
  return writing("delete", async () => {
1490
+ const context = await hookContext();
1491
+ if (hooks.beforeDelete) {
1492
+ const [existing] = await getDb().select().from(table).where(byId(id)).limit(1);
1493
+ await hooks.beforeDelete({ ...context, id, current: existing ?? null });
1494
+ }
1401
1495
  const deleted = await getDb().delete(table).where(byId(id)).returning({ id: idColumn });
1402
- return deleted.length ? { ok: true, data: { id } } : notFound();
1496
+ if (deleted.length === 0) return notFound();
1497
+ await hooks.afterDelete?.({ ...context, id });
1498
+ return { ok: true, data: { id } };
1403
1499
  });
1404
1500
  }
1405
1501
  };
@@ -1625,8 +1721,16 @@ function openApiDocument(options) {
1625
1721
  },
1626
1722
  PageMeta: {
1627
1723
  type: "object",
1628
- properties: { page: { type: "integer" }, perPage: { type: "integer" }, total: { type: "integer" }, totalPages: { type: "integer" } },
1629
- required: ["page", "perPage", "total", "totalPages"]
1724
+ properties: {
1725
+ page: { type: "integer" },
1726
+ perPage: { type: "integer" },
1727
+ total: { type: "integer", description: "Matching rows, counted up to 10,000." },
1728
+ totalPages: { type: "integer" },
1729
+ exactTotal: { type: "boolean", description: "False when there are more than 10,000 matches; page with the cursors instead." },
1730
+ nextCursor: { type: "string", description: "Pass as ?cursor= for the next page." },
1731
+ prevCursor: { type: "string" }
1732
+ },
1733
+ required: ["page", "perPage", "total", "totalPages", "exactTotal"]
1630
1734
  }
1631
1735
  };
1632
1736
  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.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Flare runtime: resource descriptors, field grammar, and Cloudflare-native helpers",
5
5
  "license": "MIT",
6
6
  "author": "Muke Johnbaptist",