@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-BqRg9RVM.js';
2
- export { B as BelongsToField, a as BooleanField, D as DEFAULT_FILE_MAX_BYTES, b as DateField, c as DateTimeField, E as EnumField, d as FILE_CATEGORIES, e as FieldKind, f as FieldValue, g as FileCategory, h as FileField, i as FloatField, H as HasManyField, I as IntField, M as MultiSelectField, P as Policy, j as PolicyAction, k as PolicyConfig, l as PolicyError, m as RESERVED_FIELD_NAMES, n as RecordOf, o as ResourceConfig, p as ResourceDefinitionError, q as STRING_FORMATS, r as StringField, s as StringFormat, T as TextField, t as allowedActions, u as can, v as columnName, w as defaultFieldLabel, x as definePolicy, y as defineResource, z as field, A as fileMaxBytes, G as mimeTypesFor, J as storedFields } from './policy-BqRg9RVM.js';
1
+ import { F as Field, C as CreateInput, U as UpdateInput, R as Resource, S as StoredField } from './policy-CvfE-wCK.js';
2
+ export { B as BelongsToField, a as BooleanField, D as DEFAULT_FILE_MAX_BYTES, b as DateField, c as DateTimeField, E as EnumField, d as FILE_CATEGORIES, e as FieldKind, f as FieldValue, g as FileCategory, h as FileField, i as FloatField, H as HasManyField, I as IntField, M as MultiSelectField, P as Policy, j as PolicyAction, k as PolicyConfig, l as PolicyError, m as RESERVED_FIELD_NAMES, n as RecordOf, o as ResourceConfig, p as ResourceDefinitionError, q as STRING_FORMATS, r as StringField, s as StringFormat, T as TextField, t as allowedActions, u as can, v as columnName, w as defaultFieldLabel, x as definePolicy, y as defineResource, z as field, A as fileMaxBytes, G as mimeTypesFor, J as storedFields } from './policy-CvfE-wCK.js';
3
3
  import { z } from 'zod';
4
4
 
5
5
  /**
@@ -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.3.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-434JXSDV.js";
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.3.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-BqRg9RVM.js';
3
+ import { S as StoredField, R as Resource, F as Field, P as Policy } from './policy-CvfE-wCK.js';
4
4
 
5
5
  interface ListQuery {
6
6
  page: number;
@@ -12,6 +12,19 @@ interface ListQuery {
12
12
  q?: string;
13
13
  /** Field key → coerced value; `null` means IS NULL. */
14
14
  filters: Record<string, string | number | boolean | null>;
15
+ /**
16
+ * Where to carry on from, instead of counting rows to skip. `OFFSET 500000` makes
17
+ * SQLite walk half a million index entries before it reads anything (8s at a million
18
+ * rows); a cursor turns that into a range scan (~100ms).
19
+ */
20
+ cursor?: Cursor;
21
+ }
22
+ /** The last row of the page you came from, and which way you're going. */
23
+ interface Cursor {
24
+ /** The sort field's value on that row. */
25
+ value: string | number | boolean | null;
26
+ id: string;
27
+ direction: "after" | "before";
15
28
  }
16
29
  interface QueryIssue {
17
30
  param: string;
@@ -58,8 +71,18 @@ interface ListResult<T = Record<string, unknown>> {
58
71
  meta: {
59
72
  page: number;
60
73
  perPage: number;
74
+ /** Rows matching the query, counted up to `COUNT_LIMIT`. */
61
75
  total: number;
62
76
  totalPages: number;
77
+ /**
78
+ * Whether `total` is the real number. Counting every row of a large table on every
79
+ * page load is the single most expensive thing a list does, so the count stops at
80
+ * `COUNT_LIMIT` and says so; past that, the list pages by cursor.
81
+ */
82
+ exactTotal: boolean;
83
+ /** Carry on from the end of this page, and from its start, without an offset. */
84
+ nextCursor?: string;
85
+ prevCursor?: string;
63
86
  };
64
87
  }
65
88
  interface ChangeEvent {
@@ -70,6 +93,19 @@ interface ChangeEvent {
70
93
  }
71
94
  interface ResourceStoreOptions {
72
95
  resource: Resource;
96
+ /**
97
+ * Who is making the write, for the descriptor's hooks. The dashboard and the API both
98
+ * know; a seed doesn't, and passes nothing.
99
+ */
100
+ currentUser?: () => Promise<{
101
+ id: string;
102
+ email: string;
103
+ role?: string | null;
104
+ } | null> | {
105
+ id: string;
106
+ email: string;
107
+ role?: string | null;
108
+ } | null;
73
109
  table: SQLiteTable;
74
110
  getDb: () => AnyDatabase;
75
111
  /**
@@ -89,7 +125,7 @@ interface ResourceStoreOptions {
89
125
  declare function createResourceStore(options: ResourceStoreOptions): {
90
126
  resource: Resource<Record<string, Field>>;
91
127
  table: SQLiteTable<drizzle_orm_sqlite_core.TableConfig>;
92
- /** `params`: page, perPage, sort, q, filter[field] (as parsed by parseListQuery). */
128
+ /** `params`: page, perPage, sort, q, filter[field], cursor (as parsed by parseListQuery). */
93
129
  list(params: URLSearchParams): Promise<Result<ListResult>>;
94
130
  get(id: string): Promise<Result<Record<string, unknown>>>;
95
131
  /** Title-field values for a set of ids (for showing relations). Missing ids are omitted. */
@@ -118,6 +154,8 @@ interface ResourceHandlerOptions {
118
154
  table: SQLiteTable;
119
155
  getDb: () => AnyDatabase;
120
156
  authorize: Authorize;
157
+ /** Who is making the request, for the descriptor's hooks. */
158
+ currentUser?: ResourceStoreOptions["currentUser"];
121
159
  /** Called after a write succeeds, for cache invalidation. */
122
160
  onChange?: (event: ChangeEvent) => void | Promise<void>;
123
161
  }
package/dist/server.js CHANGED
@@ -2,7 +2,7 @@ import {
2
2
  can,
3
3
  createValidators,
4
4
  storedFields
5
- } from "./chunk-434JXSDV.js";
5
+ } from "./chunk-PWP4QEZV.js";
6
6
 
7
7
  // ../../node_modules/.pnpm/drizzle-orm@0.45.2_@cloudfl_b372b29e6d5511672c7f9f9d2ac40572/node_modules/drizzle-orm/entity.js
8
8
  var entityKind = /* @__PURE__ */ Symbol.for("drizzle:entityKind");
@@ -1187,6 +1187,20 @@ function count(expression) {
1187
1187
  }
1188
1188
 
1189
1189
  // src/server/query.ts
1190
+ function encodeCursor(cursor) {
1191
+ const json2 = JSON.stringify([cursor.value, cursor.id, cursor.direction === "before" ? 0 : 1]);
1192
+ return btoa(json2).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
1193
+ }
1194
+ function decodeCursor(raw) {
1195
+ try {
1196
+ const json2 = atob(raw.replace(/-/g, "+").replace(/_/g, "/"));
1197
+ const parsed = JSON.parse(json2);
1198
+ if (!Array.isArray(parsed) || typeof parsed[1] !== "string") return void 0;
1199
+ return { value: parsed[0], id: parsed[1], direction: parsed[2] === 0 ? "before" : "after" };
1200
+ } catch {
1201
+ return void 0;
1202
+ }
1203
+ }
1190
1204
  var TIMESTAMP_FIELDS = ["createdAt", "updatedAt"];
1191
1205
  var MAX_PER_PAGE = 100;
1192
1206
  var MAX_SEARCH_LENGTH = 200;
@@ -1239,6 +1253,12 @@ function parseListQuery(resource, params) {
1239
1253
  else issues.push({ param: "sort", message: `can't sort by "${field}"` });
1240
1254
  }
1241
1255
  const q = params.get("q")?.trim().slice(0, MAX_SEARCH_LENGTH) || void 0;
1256
+ const rawCursor = params.get("cursor");
1257
+ let cursor;
1258
+ if (rawCursor) {
1259
+ cursor = decodeCursor(rawCursor);
1260
+ if (!cursor) issues.push({ param: "cursor", message: "isn't a cursor this list handed out" });
1261
+ }
1242
1262
  const filters = {};
1243
1263
  for (const [name, raw] of params) {
1244
1264
  const match = /^filter\[(.+)\]$/.exec(name);
@@ -1253,10 +1273,17 @@ function parseListQuery(resource, params) {
1253
1273
  if (value === void 0) issues.push({ param: name, message: `invalid value "${raw}"` });
1254
1274
  else filters[key] = value;
1255
1275
  }
1256
- return issues.length ? { issues } : { query: { page, perPage, sort, q, filters } };
1276
+ return issues.length ? { issues } : { query: { page, perPage, sort, q, filters, cursor } };
1257
1277
  }
1258
1278
 
1259
1279
  // src/server/store.ts
1280
+ var COUNT_LIMIT = 1e4;
1281
+ function toCursorValue(value) {
1282
+ if (value instanceof Date) return value.getTime();
1283
+ if (value === null || value === void 0) return null;
1284
+ if (typeof value === "object") return String(value);
1285
+ return value;
1286
+ }
1260
1287
  function errorText(error) {
1261
1288
  const parts = [];
1262
1289
  for (let current = error, depth = 0; current && depth < 5; depth++) {
@@ -1322,14 +1349,29 @@ function createResourceStore(options) {
1322
1349
  const invalid = (issues) => fail(422, "Validation failed.", { issues: issues.map((issue) => ({ path: issue.path.map(String).join("."), message: issue.message })) });
1323
1350
  const byId = (id) => eq(idColumn, id);
1324
1351
  const notFound = () => fail(404, `${resource.label} not found.`);
1352
+ const computed = Object.entries(resource.computed ?? {});
1353
+ const withComputed = (row) => {
1354
+ if (computed.length === 0) return row;
1355
+ const result = { ...row };
1356
+ for (const [key, compute] of computed) {
1357
+ try {
1358
+ result[key] = compute(row);
1359
+ } catch {
1360
+ result[key] = null;
1361
+ }
1362
+ }
1363
+ return result;
1364
+ };
1365
+ const hooks = resource.hooks ?? {};
1366
+ const hookContext = async () => ({ db: getDb(), user: await options.currentUser?.() ?? null });
1325
1367
  return {
1326
1368
  resource,
1327
1369
  table,
1328
- /** `params`: page, perPage, sort, q, filter[field] (as parsed by parseListQuery). */
1370
+ /** `params`: page, perPage, sort, q, filter[field], cursor (as parsed by parseListQuery). */
1329
1371
  async list(params) {
1330
1372
  const parsed = parseListQuery(resource, params);
1331
1373
  if ("issues" in parsed) return fail(400, "Invalid query.", { queryIssues: parsed.issues });
1332
- const { page, perPage, sort, q, filters } = parsed.query;
1374
+ const { page, perPage, sort, q, filters, cursor } = parsed.query;
1333
1375
  const conditions = [];
1334
1376
  if (q) {
1335
1377
  const pattern = `%${q.replace(/[\\%_]/g, (char) => `\\${char}`)}%`;
@@ -1339,22 +1381,56 @@ function createResourceStore(options) {
1339
1381
  for (const [key, value] of Object.entries(filters)) {
1340
1382
  conditions.push(value === null ? isNull(columns[key]) : eq(columns[key], value));
1341
1383
  }
1342
- const where = conditions.length ? and(...conditions) : void 0;
1343
- const order = sort.direction === "asc" ? asc(columns[sort.field]) : desc(columns[sort.field]);
1384
+ const filterWhere = conditions.length ? and(...conditions) : void 0;
1385
+ const sortColumn = columns[sort.field];
1386
+ const backwards = cursor?.direction === "before";
1387
+ const descending = backwards ? sort.direction === "asc" : sort.direction === "desc";
1388
+ const order = descending ? [desc(sortColumn), desc(idColumn)] : [asc(sortColumn), asc(idColumn)];
1389
+ const pageConditions = [...conditions];
1390
+ if (cursor) {
1391
+ const value = cursor.value;
1392
+ pageConditions.push(
1393
+ descending ? or(sql`${sortColumn} < ${value}`, and(sql`${sortColumn} = ${value}`, sql`${idColumn} < ${cursor.id}`)) : or(sql`${sortColumn} > ${value}`, and(sql`${sortColumn} = ${value}`, sql`${idColumn} > ${cursor.id}`))
1394
+ );
1395
+ }
1396
+ const where = pageConditions.length ? and(...pageConditions) : void 0;
1344
1397
  const db = getDb();
1345
- const [rows, totals] = await Promise.all([
1346
- db.select().from(table).where(where).orderBy(order, asc(idColumn)).limit(perPage).offset((page - 1) * perPage),
1347
- db.select({ total: count() }).from(table).where(where)
1398
+ const query = db.select().from(table).where(where).orderBy(...order).limit(perPage + 1);
1399
+ const [found, counted] = await Promise.all([
1400
+ cursor ? query : query.offset((page - 1) * perPage),
1401
+ // One row past the limit is enough to know there's more without counting it all.
1402
+ db.select({ total: count() }).from(db.select({ id: idColumn }).from(table).where(filterWhere).limit(COUNT_LIMIT + 1).as("capped"))
1348
1403
  ]);
1349
- const total = totals[0]?.total ?? 0;
1404
+ const hasMore = found.length > perPage;
1405
+ const rows = (hasMore ? found.slice(0, perPage) : found).map(withComputed);
1406
+ if (backwards) rows.reverse();
1407
+ const counting = counted[0]?.total ?? 0;
1408
+ const exactTotal = counting <= COUNT_LIMIT;
1409
+ const total = exactTotal ? counting : COUNT_LIMIT;
1410
+ const first = rows[0];
1411
+ const last = rows[rows.length - 1];
1412
+ const moreAfter = backwards ? true : hasMore;
1413
+ const moreBefore = backwards ? hasMore : Boolean(cursor) || page > 1;
1414
+ const cursorFor = (row, direction) => row ? encodeCursor({ value: toCursorValue(row[sort.field]), id: String(row.id), direction }) : void 0;
1350
1415
  return {
1351
1416
  ok: true,
1352
- data: { data: rows, meta: { page, perPage, total, totalPages: Math.max(1, Math.ceil(total / perPage)) } }
1417
+ data: {
1418
+ data: rows,
1419
+ meta: {
1420
+ page,
1421
+ perPage,
1422
+ total,
1423
+ totalPages: Math.max(1, Math.ceil(total / perPage)),
1424
+ exactTotal,
1425
+ nextCursor: moreAfter ? cursorFor(last, "after") : void 0,
1426
+ prevCursor: moreBefore ? cursorFor(first, "before") : void 0
1427
+ }
1428
+ }
1353
1429
  };
1354
1430
  },
1355
1431
  async get(id) {
1356
1432
  const [record] = await getDb().select().from(table).where(byId(id)).limit(1);
1357
- return record ? { ok: true, data: record } : notFound();
1433
+ return record ? { ok: true, data: withComputed(record) } : notFound();
1358
1434
  },
1359
1435
  /** Title-field values for a set of ids (for showing relations). Missing ids are omitted. */
1360
1436
  async titles(ids) {
@@ -1368,9 +1444,13 @@ function createResourceStore(options) {
1368
1444
  const result = validators.create.safeParse(input);
1369
1445
  if (!result.success) return Promise.resolve(invalid(result.error.issues));
1370
1446
  return writing("create", async () => {
1447
+ const context = await hookContext();
1448
+ const input2 = hooks.beforeCreate ? await hooks.beforeCreate({ ...result.data }, context) : result.data;
1371
1449
  const now = /* @__PURE__ */ new Date();
1372
- const [record] = await getDb().insert(table).values({ ...result.data, id: crypto.randomUUID(), createdAt: now, updatedAt: now }).returning();
1373
- return { ok: true, data: record };
1450
+ const [record] = await getDb().insert(table).values({ ...input2, id: crypto.randomUUID(), createdAt: now, updatedAt: now }).returning();
1451
+ const saved = withComputed(record);
1452
+ await hooks.afterCreate?.(saved, context);
1453
+ return { ok: true, data: saved };
1374
1454
  });
1375
1455
  },
1376
1456
  /** Partial update (PATCH semantics). */
@@ -1378,8 +1458,15 @@ function createResourceStore(options) {
1378
1458
  const result = validators.update.safeParse(input);
1379
1459
  if (!result.success) return Promise.resolve(invalid(result.error.issues));
1380
1460
  return writing("update", async () => {
1381
- const [record] = await getDb().update(table).set({ ...result.data, updatedAt: /* @__PURE__ */ new Date() }).where(byId(id)).returning();
1382
- return record ? { ok: true, data: record } : notFound();
1461
+ const context = await hookContext();
1462
+ const [existing] = hooks.beforeUpdate || hooks.afterUpdate ? await getDb().select().from(table).where(byId(id)).limit(1) : [];
1463
+ const current = existing ?? null;
1464
+ const input2 = hooks.beforeUpdate ? await hooks.beforeUpdate({ ...result.data }, { ...context, id, current }) : result.data;
1465
+ const [record] = await getDb().update(table).set({ ...input2, updatedAt: /* @__PURE__ */ new Date() }).where(byId(id)).returning();
1466
+ if (!record) return notFound();
1467
+ const saved = withComputed(record);
1468
+ await hooks.afterUpdate?.(saved, { ...context, previous: current });
1469
+ return { ok: true, data: saved };
1383
1470
  });
1384
1471
  },
1385
1472
  /** Full replacement (PUT semantics): omitted fields go back to their default, or null when optional. */
@@ -1393,13 +1480,20 @@ function createResourceStore(options) {
1393
1480
  }
1394
1481
  Object.assign(values, result.data, { updatedAt: /* @__PURE__ */ new Date() });
1395
1482
  const [record] = await getDb().update(table).set(values).where(byId(id)).returning();
1396
- return record ? { ok: true, data: record } : notFound();
1483
+ return record ? { ok: true, data: withComputed(record) } : notFound();
1397
1484
  });
1398
1485
  },
1399
1486
  delete(id) {
1400
1487
  return writing("delete", async () => {
1488
+ const context = await hookContext();
1489
+ if (hooks.beforeDelete) {
1490
+ const [existing] = await getDb().select().from(table).where(byId(id)).limit(1);
1491
+ await hooks.beforeDelete({ ...context, id, current: existing ?? null });
1492
+ }
1401
1493
  const deleted = await getDb().delete(table).where(byId(id)).returning({ id: idColumn });
1402
- return deleted.length ? { ok: true, data: { id } } : notFound();
1494
+ if (deleted.length === 0) return notFound();
1495
+ await hooks.afterDelete?.({ ...context, id });
1496
+ return { ok: true, data: { id } };
1403
1497
  });
1404
1498
  }
1405
1499
  };
@@ -1625,8 +1719,16 @@ function openApiDocument(options) {
1625
1719
  },
1626
1720
  PageMeta: {
1627
1721
  type: "object",
1628
- properties: { page: { type: "integer" }, perPage: { type: "integer" }, total: { type: "integer" }, totalPages: { type: "integer" } },
1629
- required: ["page", "perPage", "total", "totalPages"]
1722
+ properties: {
1723
+ page: { type: "integer" },
1724
+ perPage: { type: "integer" },
1725
+ total: { type: "integer", description: "Matching rows, counted up to 10,000." },
1726
+ totalPages: { type: "integer" },
1727
+ exactTotal: { type: "boolean", description: "False when there are more than 10,000 matches; page with the cursors instead." },
1728
+ nextCursor: { type: "string", description: "Pass as ?cursor= for the next page." },
1729
+ prevCursor: { type: "string" }
1730
+ },
1731
+ required: ["page", "perPage", "total", "totalPages", "exactTotal"]
1630
1732
  }
1631
1733
  };
1632
1734
  const tags = [{ name: "Authentication", description: "Sign in here first: the session cookie authorizes every other request." }];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flaredev/core",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Flare runtime: resource descriptors, field grammar, and Cloudflare-native helpers",
5
5
  "license": "MIT",
6
6
  "author": "Muke Johnbaptist",