@flaredev/core 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -152,6 +152,9 @@ function defineResource(config) {
|
|
|
152
152
|
label: config.label ?? humanize(name),
|
|
153
153
|
pluralLabel: config.pluralLabel ?? humanize(pluralName),
|
|
154
154
|
icon: config.icon,
|
|
155
|
+
group: config.group,
|
|
156
|
+
hooks: config.hooks ?? {},
|
|
157
|
+
computed: config.computed ?? {},
|
|
155
158
|
titleField,
|
|
156
159
|
defaultSort,
|
|
157
160
|
perPage,
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { F as Field, C as CreateInput, U as UpdateInput, R as Resource, S as StoredField } from './policy-
|
|
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
|
/**
|
|
@@ -507,6 +507,6 @@ interface SeedContext {
|
|
|
507
507
|
type Seed = (context: SeedContext) => void | Promise<void>;
|
|
508
508
|
declare function defineSeed(seed: Seed): Seed;
|
|
509
509
|
|
|
510
|
-
declare const FLARE_VERSION = "0.
|
|
510
|
+
declare const FLARE_VERSION = "0.4.0";
|
|
511
511
|
|
|
512
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";
|
|
@@ -962,7 +962,7 @@ function createFake(seed = 20260101) {
|
|
|
962
962
|
var fake = createFake();
|
|
963
963
|
|
|
964
964
|
// src/index.ts
|
|
965
|
-
var FLARE_VERSION = "0.
|
|
965
|
+
var FLARE_VERSION = "0.4.0";
|
|
966
966
|
export {
|
|
967
967
|
DEFAULT_FILE_MAX_BYTES,
|
|
968
968
|
FILE_CATEGORIES,
|
|
@@ -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." }];
|