@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.
- package/dist/{chunk-434JXSDV.js → chunk-VLHPSNQN.js} +26 -1
- package/dist/index.d.ts +8 -6
- package/dist/index.js +35 -2
- package/dist/{policy-BqRg9RVM.d.ts → policy-DaKcWZ5g.d.ts} +75 -3
- package/dist/server.d.ts +44 -6
- package/dist/server.js +130 -26
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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-
|
|
2
|
-
export { B as BelongsToField, a as BooleanField, D as DEFAULT_FILE_MAX_BYTES,
|
|
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.
|
|
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-
|
|
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.
|
|
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 {
|
|
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-
|
|
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(
|
|
133
|
+
create(raw: unknown): Promise<Result<Record<string, unknown>>>;
|
|
98
134
|
/** Partial update (PATCH semantics). */
|
|
99
|
-
update(id: string,
|
|
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(
|
|
148
|
-
update(id: string,
|
|
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-
|
|
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
|
|
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) {
|
|
@@ -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(
|
|
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({ ...
|
|
1373
|
-
|
|
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,
|
|
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
|
|
1382
|
-
|
|
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
|
-
|
|
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: {
|
|
1629
|
-
|
|
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." }];
|