@flaredev/core 0.11.0 → 0.12.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,3 +1,120 @@
1
+ import { b as RateLimitConfig } from './rate-limit-ClbdZioJ.js';
2
+
3
+ /**
4
+ * One shape for every error the API returns.
5
+ *
6
+ * There used to be four: `{ error }`, `{ error, field }`, `{ error, issues }` with a
7
+ * `path` in each issue, and `{ error, issues }` with a `param` in each issue instead —
8
+ * plus a plain-text `Access denied.` from the security layer. A client had to handle all
9
+ * of them, and which one it got depended on which guard happened to refuse the request.
10
+ *
11
+ * Now there is one:
12
+ *
13
+ * ```json
14
+ * {
15
+ * "error": "Number is already taken.",
16
+ * "code": "unique_violation",
17
+ * "issues": [{ "path": "number", "message": "Number is already taken.", "code": "unique" }]
18
+ * }
19
+ * ```
20
+ *
21
+ * `error` is for a person — it is the sentence a form or a log shows, and it is the only
22
+ * field whose wording may change between releases. `code` is for a program: it is stable,
23
+ * so a client can translate it, branch on it, or decide whether retrying is worth it.
24
+ * `issues` says which fields, and is present whenever the answer is about fields at all —
25
+ * one issue for a unique conflict, one per failed rule for a validation error, one per
26
+ * bad parameter for a query. A client that only wants to put messages under inputs can
27
+ * read `issues` and ignore everything else.
28
+ */
29
+ /**
30
+ * Why a request failed, as a value a program can depend on.
31
+ *
32
+ * Add to this rather than inventing a string at a call site: the point of a code is that
33
+ * it is a closed set a client can switch over.
34
+ */
35
+ declare const PROBLEM_CODES: {
36
+ /** The body broke a field's rules. `issues` says which fields. */
37
+ readonly validationFailed: "validation_failed";
38
+ /** A unique column already holds this value. `issues` names the column. */
39
+ readonly uniqueViolation: "unique_violation";
40
+ /** Deleting this would orphan rows that point at it. */
41
+ readonly stillReferenced: "still_referenced";
42
+ /** A relation field names a record that does not exist. */
43
+ readonly relatedRecordMissing: "related_record_missing";
44
+ /** A value outside what its column accepts — an enum option that is not one. */
45
+ readonly valueNotAllowed: "value_not_allowed";
46
+ /** No such record, or none this request may see. */
47
+ readonly notFound: "not_found";
48
+ /** A query parameter is wrong. `issues` names the parameters. */
49
+ readonly invalidQuery: "invalid_query";
50
+ /** The body was not JSON. */
51
+ readonly invalidJson: "invalid_json";
52
+ /** The request did not say `Content-Type: application/json`. */
53
+ readonly unsupportedMediaType: "unsupported_media_type";
54
+ /** A write from another origin: the CSRF guard. */
55
+ readonly crossOriginBlocked: "cross_origin_blocked";
56
+ /** No session and no API key. */
57
+ readonly unauthenticated: "unauthenticated";
58
+ /** Signed in, but this role may not do this. */
59
+ readonly forbidden: "forbidden";
60
+ /** Refused by the security layer before the app saw it. */
61
+ readonly accessDenied: "access_denied";
62
+ /** Too many requests. */
63
+ readonly rateLimited: "rate_limited";
64
+ /** A signed storage URL that is missing, malformed, expired, or not for this operation. */
65
+ readonly invalidToken: "invalid_token";
66
+ /** An upload past the limit the signed URL allows. */
67
+ readonly payloadTooLarge: "payload_too_large";
68
+ /** An upload with no Content-Length, which cannot be checked against that limit. */
69
+ readonly lengthRequired: "length_required";
70
+ /** A request that is wrong in a way none of the others describes. */
71
+ readonly badRequest: "bad_request";
72
+ /** Something went wrong that the server does not explain. */
73
+ readonly internal: "internal";
74
+ };
75
+ type ProblemCode = (typeof PROBLEM_CODES)[keyof typeof PROBLEM_CODES];
76
+ /**
77
+ * Why one field is wrong.
78
+ *
79
+ * `path` is the field key, dotted for a nested one (`address.city`), or the parameter
80
+ * name for a query issue. `code` is optional only because a rule that has no better
81
+ * answer than "invalid" should not be forced to invent one.
82
+ */
83
+ interface ProblemIssue {
84
+ path: string;
85
+ message: string;
86
+ code?: IssueCode;
87
+ }
88
+ /** Why one field is wrong, as a value a program can depend on. */
89
+ declare const ISSUE_CODES: {
90
+ /** Broke the field's own rules: a format, a length, a required value. */
91
+ readonly invalid: "invalid";
92
+ /** A unique column already holds this. */
93
+ readonly unique: "unique";
94
+ /** Set once and not changeable — an ownership column, for instance. */
95
+ readonly immutable: "immutable";
96
+ /** A query parameter rather than a body field. */
97
+ readonly invalidParameter: "invalid_parameter";
98
+ };
99
+ type IssueCode = (typeof ISSUE_CODES)[keyof typeof ISSUE_CODES];
100
+ /** The body of every error response. */
101
+ interface ProblemBody {
102
+ error: string;
103
+ code: ProblemCode;
104
+ issues?: ProblemIssue[];
105
+ }
106
+ /**
107
+ * Whether this caller wants JSON.
108
+ *
109
+ * Used where a guard answers both browsers and programs — the security layer sits in
110
+ * front of pages as well as the API — because a browser navigation that renders a page of
111
+ * JSON is worse than one that renders a sentence. An `Accept` header asking for JSON, or
112
+ * a path under `/api/`, means a program is asking.
113
+ */
114
+ declare function wantsJson(request: Request): boolean;
115
+ /** An error response in the one shape. */
116
+ declare function problemResponse(status: number, error: string, code: ProblemCode, issues?: ProblemIssue[], init?: ResponseInit): Response;
117
+
1
118
  /**
2
119
  * Fields that only apply sometimes.
3
120
  *
@@ -72,6 +189,20 @@ interface CommonOptions<T> {
72
189
  * time. `required` then means "required *when it applies*" rather than "never null".
73
190
  */
74
191
  showWhen?: FieldCondition;
192
+ /**
193
+ * This field used to be called something else, and the column holds data worth keeping:
194
+ *
195
+ * fullName: field.string({ rename: { from: "name" } })
196
+ *
197
+ * Without it a rename is a drop and an add, which empties the column. The descriptor
198
+ * knows the two names are the same column; the migration should too.
199
+ *
200
+ * It applies once. Once the column has been renamed the declaration does nothing, so it
201
+ * is safe to leave in place and safe to delete on the next edit.
202
+ */
203
+ rename?: {
204
+ from: string;
205
+ };
75
206
  }
76
207
  /**
77
208
  * What a string holds. Each format is still a text column; the format decides
@@ -351,7 +482,7 @@ interface ResourceConfig<Fields extends Record<string, Field>> {
351
482
  /** Field used to represent a record in lists and relation pickers. Default: first string field. */
352
483
  titleField?: FieldKey<Fields>;
353
484
  defaultSort?: {
354
- field: FieldKey<Fields> | "createdAt" | "updatedAt";
485
+ field: FieldKey<Fields> | "id" | "createdAt" | "updatedAt";
355
486
  direction: "asc" | "desc";
356
487
  };
357
488
  /** Page size for list endpoints and tables. Default 25. */
@@ -370,6 +501,18 @@ interface ResourceConfig<Fields extends Record<string, Field>> {
370
501
  * decision rather than an accident.
371
502
  */
372
503
  softDelete?: boolean;
504
+ /**
505
+ * How often each action may be called, per signed-in account (or per IP when there is
506
+ * nobody signed in):
507
+ *
508
+ * rateLimit: { create: "10/min", "*": "120/min" }
509
+ *
510
+ * The named action wins over `*`. Enforced in the generated route, before the store is
511
+ * touched, so a refused call costs nothing but the count. Worth setting on the actions
512
+ * that cost something — a create that sends an email, an export that reads the whole
513
+ * table — rather than on everything.
514
+ */
515
+ rateLimit?: RateLimitConfig;
373
516
  /**
374
517
  * Your logic, beside the fields it belongs to. Every write — the REST API, the
375
518
  * dashboard's forms, a seed, an import — goes through the same store, so a hook here
@@ -436,6 +579,8 @@ interface Resource<Fields extends Record<string, Field> = Record<string, Field>>
436
579
  perPage: number;
437
580
  /** Deleted records are kept and hidden, not removed. See ResourceConfig.softDelete. */
438
581
  softDelete: boolean;
582
+ /** How often each action may be called. See ResourceConfig.rateLimit. */
583
+ rateLimit?: RateLimitConfig;
439
584
  /** Fields with labels filled in. */
440
585
  fields: Fields & Record<string, Field & {
441
586
  label: string;
@@ -557,4 +702,4 @@ declare function ownershipFilter(policy: Policy | undefined | null, user: {
557
702
  /** Actions `role` may perform, for hiding UI it can't use. */
558
703
  declare function allowedActions(policy: Policy | undefined | null, role: string | null | undefined): Record<Exclude<PolicyAction, "list">, boolean>;
559
704
 
560
- export { fileMaxBytes as $, type TextField as A, type BelongsToField as B, type CreateInput as C, DEFAULT_FILE_MAX_BYTES as D, type EnumField as E, type Field as F, type TextFormat as G, type HasManyField as H, type IntField as I, type JsonField as J, allowedActions as K, can as L, type MultiSelectField as M, NUMBER_FORMATS as N, clientResource as O, type Policy as P, columnName as Q, type Resource as R, type StoredField as S, TEXT_FORMATS as T, type UpdateInput as U, conditionHolds as V, defaultFieldLabel as W, definePolicy as X, defineResource as Y, describeCondition as Z, field as _, type BooleanField as a, mimeTypesFor as a0, ownershipFilter as a1, storedFields as a2, type ClientResource as b, type DateField as c, type DateTimeField as d, FILE_CATEGORIES as e, type FieldCondition as f, type FieldKind as g, type FieldValue as h, type FileCategory as i, type FileField as j, type FloatField as k, type JsonOptions as l, type NumberFormat as m, type PolicyAction as n, type PolicyConfig as o, PolicyError as p, type PolicyOwnership as q, RESERVED_FIELD_NAMES as r, type RecordOf as s, type ResourceConfig as t, ResourceDefinitionError as u, STRING_FORMATS as v, type StringField as w, type StringFormat as x, type TagsField as y, type TagsOptions as z };
705
+ export { clientResource as $, type RecordOf as A, type BelongsToField as B, type CreateInput as C, DEFAULT_FILE_MAX_BYTES as D, type EnumField as E, type Field as F, type ResourceConfig as G, type HasManyField as H, ISSUE_CODES as I, type JsonField as J, ResourceDefinitionError as K, STRING_FORMATS as L, type MultiSelectField as M, NUMBER_FORMATS as N, type StringField as O, type ProblemCode as P, type StringFormat as Q, type Resource as R, type StoredField as S, TEXT_FORMATS as T, type UpdateInput as U, type TagsField as V, type TagsOptions as W, type TextField as X, type TextFormat as Y, allowedActions as Z, can as _, type ProblemIssue as a, columnName as a0, conditionHolds as a1, defaultFieldLabel as a2, definePolicy as a3, defineResource as a4, describeCondition as a5, field as a6, fileMaxBytes as a7, mimeTypesFor as a8, ownershipFilter as a9, storedFields as aa, type Policy as b, type IssueCode as c, PROBLEM_CODES as d, type ProblemBody as e, type BooleanField as f, type ClientResource as g, type DateField as h, type DateTimeField as i, FILE_CATEGORIES as j, type FieldCondition as k, type FieldKind as l, type FieldValue as m, type FileCategory as n, type FileField as o, problemResponse as p, type FloatField as q, type IntField as r, type JsonOptions as s, type NumberFormat as t, type PolicyAction as u, type PolicyConfig as v, wantsJson as w, PolicyError as x, type PolicyOwnership as y, RESERVED_FIELD_NAMES as z };
@@ -0,0 +1,44 @@
1
+ import { DurableObject } from 'cloudflare:workers';
2
+ import { R as Rate, a as RateLimitDecision } from '../rate-limit-ClbdZioJ.js';
3
+
4
+ /**
5
+ * The Cloudflare half of rate limiting: a Durable Object that holds the counters.
6
+ *
7
+ * **One instance per resource and action, not per caller.** A Durable Object is a billed
8
+ * request, so a counter per signed-in user per endpoint would be an invoice rather than a
9
+ * feature. Instead one object holds a map of caller → count for a single
10
+ * resource-and-action pair, which is one request per limited call and a handful of
11
+ * objects for the whole app.
12
+ *
13
+ * **Counters live in memory.** If the object is evicted the window restarts, which lets a
14
+ * caller through earlier than the limit says. That is the right trade here: this is for
15
+ * stopping one account hammering one endpoint, and the alternative is a storage write on
16
+ * every request to make the boundary exact. A limit that must hold across an eviction —
17
+ * a paid quota, say — belongs in the database with the thing it is metering.
18
+ *
19
+ * The alarm is what keeps memory flat: every window, whatever has expired is dropped.
20
+ */
21
+
22
+ declare class RateLimitCounter extends DurableObject {
23
+ private counters;
24
+ /**
25
+ * Count one call against `key` and say whether it was within `rate`.
26
+ *
27
+ * Called through the stub, so the arguments cross a serialization boundary: plain data
28
+ * only, which is why the rate is passed in rather than configured on the object.
29
+ */
30
+ hit(key: string, rate: Rate): Promise<RateLimitDecision>;
31
+ /** Drop what has expired, and keep sweeping only while something is left. */
32
+ alarm(): Promise<void>;
33
+ }
34
+ /** What the generated `AppEnv` calls this binding. */
35
+ declare const RATE_LIMIT_BINDING = "FLARE_RATE_LIMIT";
36
+ /** The shape of the binding, so an app can type it without importing the Workers types. */
37
+ interface RateLimitNamespace {
38
+ idFromName(name: string): unknown;
39
+ get(id: unknown): {
40
+ hit(key: string, rate: Rate): Promise<RateLimitDecision>;
41
+ };
42
+ }
43
+
44
+ export { RATE_LIMIT_BINDING, RateLimitCounter, type RateLimitNamespace };
@@ -0,0 +1,45 @@
1
+ import {
2
+ fixedWindow
3
+ } from "../chunk-I2HSIPCF.js";
4
+
5
+ // src/rate-limit/server.ts
6
+ import { DurableObject } from "cloudflare:workers";
7
+ var SWEEP_INTERVAL = 6e4;
8
+ var RateLimitCounter = class extends DurableObject {
9
+ counters = /* @__PURE__ */ new Map();
10
+ /**
11
+ * Count one call against `key` and say whether it was within `rate`.
12
+ *
13
+ * Called through the stub, so the arguments cross a serialization boundary: plain data
14
+ * only, which is why the rate is passed in rather than configured on the object.
15
+ */
16
+ async hit(key, rate) {
17
+ const decision = await fixedWindow(rate, async (windowSeconds) => {
18
+ const now = Date.now();
19
+ const existing = this.counters.get(key);
20
+ if (!existing || existing.expires <= now) {
21
+ const counter = { count: 1, expires: now + windowSeconds * 1e3 };
22
+ this.counters.set(key, counter);
23
+ return { count: 1, ttl: windowSeconds };
24
+ }
25
+ existing.count += 1;
26
+ return { count: existing.count, ttl: (existing.expires - now) / 1e3 };
27
+ });
28
+ const alarm = await this.ctx.storage.getAlarm();
29
+ if (alarm === null) await this.ctx.storage.setAlarm(Date.now() + SWEEP_INTERVAL);
30
+ return decision;
31
+ }
32
+ /** Drop what has expired, and keep sweeping only while something is left. */
33
+ async alarm() {
34
+ const now = Date.now();
35
+ for (const [key, counter] of this.counters) {
36
+ if (counter.expires <= now) this.counters.delete(key);
37
+ }
38
+ if (this.counters.size > 0) await this.ctx.storage.setAlarm(now + SWEEP_INTERVAL);
39
+ }
40
+ };
41
+ var RATE_LIMIT_BINDING = "FLARE_RATE_LIMIT";
42
+ export {
43
+ RATE_LIMIT_BINDING,
44
+ RateLimitCounter
45
+ };
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Rate limiting per resource and per action.
3
+ *
4
+ * A descriptor says how often each action may be called:
5
+ *
6
+ * ```ts
7
+ * rateLimit: { create: "10/min", "*": "120/min" }
8
+ * ```
9
+ *
10
+ * and the generated route enforces it before the store is touched. The point is the
11
+ * actions that cost something — a create that sends an email, an export that reads the
12
+ * whole table — not a blanket limit on everything, which is what the edge already does.
13
+ *
14
+ * **Why not the platform's own limiter.** Cloudflare has a rate-limiting binding and it
15
+ * is cheaper than anything here, but its window may only be 10 or 60 seconds and its
16
+ * limit is fixed in the Worker's configuration rather than per call. A descriptor that
17
+ * said `"500/hour"` would work on one stack and not the other, and a descriptor that
18
+ * behaves differently per stack is the one thing Flare does not do. So the counter is
19
+ * Flare's: a Durable Object on Cloudflare, Redis on Next.js, the same arithmetic on both.
20
+ *
21
+ * The edge limiter is still worth having and is unaffected — `flare gen security`
22
+ * configures it. It stops floods; this stops one signed-in account from calling one
23
+ * endpoint too often, which is a different question.
24
+ */
25
+ /** A limit, once parsed: so many calls per so many seconds. */
26
+ interface Rate {
27
+ limit: number;
28
+ seconds: number;
29
+ }
30
+ declare class RateSyntaxError extends Error {
31
+ constructor(message: string);
32
+ }
33
+ /**
34
+ * `"10/min"` → `{ limit: 10, seconds: 60 }`.
35
+ *
36
+ * Also takes a count, as in `"30/5min"`, for the windows between the named ones. Throws
37
+ * rather than guessing: a rate limit that silently parsed as something else is worse than
38
+ * one that refuses to generate.
39
+ */
40
+ declare function parseRate(spec: string): Rate;
41
+ /** Actions a rate limit can be set on, plus `*` for the rest. */
42
+ type RateLimitedAction = "list" | "read" | "create" | "update" | "delete" | "*";
43
+ /** What a descriptor may say about how often it can be called. */
44
+ type RateLimitConfig = Partial<Record<RateLimitedAction, string>>;
45
+ /**
46
+ * The rate that applies to one action, or undefined when none does.
47
+ *
48
+ * The named action wins over `*`, so `{ create: "10/min", "*": "120/min" }` reads as "ten
49
+ * creates a minute, a hundred and twenty of anything else" rather than both at once.
50
+ */
51
+ declare function rateFor(config: RateLimitConfig | undefined, action: RateLimitedAction): Rate | undefined;
52
+ /** Every rate a config declares, by action, with its spec — for validation and for docs. */
53
+ declare function ratesIn(config: RateLimitConfig | undefined): {
54
+ action: RateLimitedAction;
55
+ spec: string;
56
+ rate: Rate;
57
+ }[];
58
+ /** What a counter says when it is asked. */
59
+ interface RateLimitDecision {
60
+ allowed: boolean;
61
+ /** Calls left in this window, never below zero. */
62
+ remaining: number;
63
+ /** Seconds until the window resets — what goes in `Retry-After`. */
64
+ retryAfter: number;
65
+ }
66
+ /**
67
+ * Somewhere to keep the counters.
68
+ *
69
+ * One method, because that is all a fixed window needs: count this call, and say whether
70
+ * it was within the limit. Implemented over a Durable Object on Cloudflare and over Redis
71
+ * on Next.js; both are in the app, in `lib/rate-limit.ts`, and both are yours to replace.
72
+ */
73
+ interface RateLimitStore {
74
+ hit(key: string, rate: Rate): Promise<RateLimitDecision>;
75
+ }
76
+ /**
77
+ * The key a limit is counted against: one bucket per resource, action and caller.
78
+ *
79
+ * The caller is the signed-in account where there is one and the IP otherwise, so a limit
80
+ * on `create` is a limit on that person rather than on everyone sharing an office
81
+ * connection. Anonymous callers behind one address do share a bucket, which is the right
82
+ * trade for an endpoint that does not know who is calling.
83
+ */
84
+ declare const rateLimitKey: (resource: string, action: string, caller: string) => string;
85
+ /**
86
+ * A fixed-window count, given somewhere to keep a number.
87
+ *
88
+ * Shared by both backends so the arithmetic — and the off-by-one at the boundary — is
89
+ * written once. `increment` returns the count *after* this call and the seconds left in
90
+ * the window it belongs to.
91
+ */
92
+ declare function fixedWindow(rate: Rate, increment: (windowSeconds: number) => Promise<{
93
+ count: number;
94
+ ttl: number;
95
+ }>): Promise<RateLimitDecision>;
96
+
97
+ export { type Rate as R, type RateLimitDecision as a, type RateLimitConfig as b, type RateLimitStore as c, type RateLimitedAction as d, RateSyntaxError as e, fixedWindow as f, rateLimitKey as g, ratesIn as h, parseRate as p, rateFor as r };
@@ -1,3 +1,8 @@
1
+ import {
2
+ PROBLEM_CODES,
3
+ problemResponse,
4
+ wantsJson
5
+ } from "../chunk-7NQIVWGG.js";
1
6
  import {
2
7
  clientIp,
3
8
  detectorMatches,
@@ -31,6 +36,12 @@ var SecurityMonitor = class extends DurableObject {
31
36
  await this.ctx.storage.deleteAll();
32
37
  }
33
38
  };
39
+ function refuse(request, status, message, code, extraHeaders = {}) {
40
+ const headers = { "cache-control": "no-store", ...extraHeaders };
41
+ if (wantsJson(request)) return problemResponse(status, message, code, void 0, { headers });
42
+ return new Response(`${message}
43
+ `, { status, headers: { "content-type": "text/plain; charset=utf-8", ...headers } });
44
+ }
34
45
  function createSecurity(options) {
35
46
  const { config, bans } = options;
36
47
  const allowed = new Set(config.allow ?? []);
@@ -93,15 +104,12 @@ function createSecurity(options) {
93
104
  if (remembered !== void 0 && remembered <= Date.now()) recentBans.delete(ip);
94
105
  if (remembered !== void 0 && remembered > Date.now() || await bans.get(`${BAN_PREFIX}${ip}`)) {
95
106
  ctx.waitUntil(monitor(ip).blocked());
96
- return new Response("Access denied.", { status: 403, headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" } });
107
+ return refuse(request, 403, "Access denied.", PROBLEM_CODES.accessDenied);
97
108
  }
98
109
  if (options.rateLimiter && config.rateLimit?.paths.test(new URL(request.url).pathname)) {
99
110
  const { success } = await options.rateLimiter.limit({ key: ip });
100
111
  if (!success) {
101
- return new Response("Too many requests. Slow down and try again shortly.", {
102
- status: 429,
103
- headers: { "content-type": "text/plain; charset=utf-8", "retry-after": "60", "cache-control": "no-store" }
104
- });
112
+ return refuse(request, 429, "Too many requests. Slow down and try again shortly.", PROBLEM_CODES.rateLimited, { "retry-after": "60" });
105
113
  }
106
114
  }
107
115
  const response = await next();
package/dist/server.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import * as drizzle_orm_sqlite_core from 'drizzle-orm/sqlite-core';
2
2
  import { BaseSQLiteDatabase, SQLiteTable, SQLiteColumn } from 'drizzle-orm/sqlite-core';
3
- import { S as StoredField, R as Resource, F as Field, P as Policy } from './policy-DaK3dWQ_.js';
3
+ import { P as ProblemCode, a as ProblemIssue, R as Resource, F as Field, S as StoredField, b as Policy } from './policy-b4FXN3ZJ.js';
4
+ export { I as ISSUE_CODES, c as IssueCode, d as PROBLEM_CODES, e as ProblemBody, p as problemResponse, w as wantsJson } from './policy-b4FXN3ZJ.js';
5
+ import './rate-limit-ClbdZioJ.js';
4
6
 
5
7
  /**
6
8
  * The handful of things a resource store needs a database to do.
@@ -86,59 +88,6 @@ declare function drizzleRows(table: SQLiteTable, getDb: () => AnyDatabase): Reso
86
88
  columns: Record<string, SQLiteColumn>;
87
89
  };
88
90
 
89
- interface ListQuery {
90
- page: number;
91
- perPage: number;
92
- sort: {
93
- field: string;
94
- direction: "asc" | "desc";
95
- };
96
- q?: string;
97
- /** Field key → coerced value; `null` means IS NULL. */
98
- filters: Record<string, string | number | boolean | null>;
99
- /**
100
- * Where to carry on from, instead of counting rows to skip. `OFFSET 500000` makes
101
- * SQLite walk half a million index entries before it reads anything (8s at a million
102
- * rows); a cursor turns that into a range scan (~100ms).
103
- */
104
- cursor?: Cursor;
105
- }
106
- /** The last row of the page you came from, and which way you're going. */
107
- interface Cursor {
108
- /** The sort field's value on that row. */
109
- value: string | number | boolean | null;
110
- id: string;
111
- direction: "after" | "before";
112
- }
113
- interface QueryIssue {
114
- param: string;
115
- message: string;
116
- }
117
- declare function isSortable(def: StoredField): boolean;
118
- declare function isFilterable(def: StoredField): boolean;
119
- declare function isSearchable(def: StoredField): boolean;
120
- /**
121
- * Parse `?page=2&perPage=50&sort=-createdAt&q=ada&filter[status]=lead` against a
122
- * resource. Only fields the descriptor marks sortable/filterable/searchable are
123
- * accepted, so clients can't probe arbitrary columns.
124
- */
125
- declare function parseListQuery(resource: Resource, params: URLSearchParams,
126
- /**
127
- * The largest page this caller may ask for.
128
- *
129
- * The cap exists to stop a request from the outside asking for ten thousand rows at
130
- * once. Server-side callers that page through everything deliberately — the CSV
131
- * export — pass their own, which is why this is an argument rather than a constant
132
- * everything obeys.
133
- */
134
- options?: {
135
- maxPerPage?: number;
136
- }): {
137
- query: ListQuery;
138
- } | {
139
- issues: QueryIssue[];
140
- };
141
-
142
91
  type ResourceAction = "list" | "read" | "create" | "update" | "delete";
143
92
  interface FieldIssue {
144
93
  /** Dotted field path; "" for the whole body (e.g. unknown keys). */
@@ -148,13 +97,18 @@ interface FieldIssue {
148
97
  type Failure = {
149
98
  ok: false;
150
99
  status: 400 | 404 | 409 | 422;
100
+ /** The sentence a person reads. Its wording is not part of the contract. */
151
101
  error: string;
152
- /** Validation issues per field. */
153
- issues?: FieldIssue[];
154
- /** Invalid query parameters. */
155
- queryIssues?: QueryIssue[];
156
- /** Field that caused a conflict (e.g. a unique violation). */
157
- field?: string;
102
+ /** Why, as a value a program can switch on. See PROBLEM_CODES. */
103
+ code: ProblemCode;
104
+ /**
105
+ * Which fields, whenever the answer is about fields at all: one issue for a unique
106
+ * conflict, one per failed rule for a validation error, one per bad parameter for a
107
+ * query. There used to be three different ways of saying this — `issues` with a
108
+ * `path`, `issues` with a `param`, and a bare `field` — and a client had to handle all
109
+ * of them depending on which guard refused it.
110
+ */
111
+ issues?: ProblemIssue[];
158
112
  };
159
113
  type Result<T> = {
160
114
  ok: true;
@@ -298,6 +252,59 @@ declare function createResourceHandlers(options: ResourceHandlerOptions): {
298
252
  };
299
253
  };
300
254
 
255
+ interface ListQuery {
256
+ page: number;
257
+ perPage: number;
258
+ sort: {
259
+ field: string;
260
+ direction: "asc" | "desc";
261
+ };
262
+ q?: string;
263
+ /** Field key → coerced value; `null` means IS NULL. */
264
+ filters: Record<string, string | number | boolean | null>;
265
+ /**
266
+ * Where to carry on from, instead of counting rows to skip. `OFFSET 500000` makes
267
+ * SQLite walk half a million index entries before it reads anything (8s at a million
268
+ * rows); a cursor turns that into a range scan (~100ms).
269
+ */
270
+ cursor?: Cursor;
271
+ }
272
+ /** The last row of the page you came from, and which way you're going. */
273
+ interface Cursor {
274
+ /** The sort field's value on that row. */
275
+ value: string | number | boolean | null;
276
+ id: string;
277
+ direction: "after" | "before";
278
+ }
279
+ interface QueryIssue {
280
+ param: string;
281
+ message: string;
282
+ }
283
+ declare function isSortable(def: StoredField): boolean;
284
+ declare function isFilterable(def: StoredField): boolean;
285
+ declare function isSearchable(def: StoredField): boolean;
286
+ /**
287
+ * Parse `?page=2&perPage=50&sort=-createdAt&q=ada&filter[status]=lead` against a
288
+ * resource. Only fields the descriptor marks sortable/filterable/searchable are
289
+ * accepted, so clients can't probe arbitrary columns.
290
+ */
291
+ declare function parseListQuery(resource: Resource, params: URLSearchParams,
292
+ /**
293
+ * The largest page this caller may ask for.
294
+ *
295
+ * The cap exists to stop a request from the outside asking for ten thousand rows at
296
+ * once. Server-side callers that page through everything deliberately — the CSV
297
+ * export — pass their own, which is why this is an argument rather than a constant
298
+ * everything obeys.
299
+ */
300
+ options?: {
301
+ maxPerPage?: number;
302
+ }): {
303
+ query: ListQuery;
304
+ } | {
305
+ issues: QueryIssue[];
306
+ };
307
+
301
308
  /**
302
309
  * {@link ResourceRows} over Prisma and Postgres — the Next.js stack's half.
303
310
  *
@@ -327,7 +334,7 @@ interface PrismaDelegate {
327
334
  update(args: any): Promise<any>;
328
335
  delete(args: any): Promise<any>;
329
336
  }
330
- declare function prismaRows(delegate: PrismaDelegate, client: unknown): ResourceRows;
337
+ declare function prismaRows(delegate: PrismaDelegate, client: unknown, resource?: Resource): ResourceRows;
331
338
 
332
339
  /**
333
340
  * An OpenAPI 3.1 document for an app's generated REST API, built at request time
@@ -356,4 +363,4 @@ interface OpenApiOptions {
356
363
  declare function fieldJsonSchema(def: StoredField): Schema;
357
364
  declare function openApiDocument(options: OpenApiOptions): Schema;
358
365
 
359
- export { type AnyDatabase, type Authorize, type AuthorizeContext, type ChangeEvent, type ConstraintHit, type Failure, type FieldIssue, type ListQuery, type ListResult, type OpenApiOptions, type PrismaDelegate, type QueryIssue, type ResourceAction, type ResourceHandlerOptions, type ResourceRows, type ResourceStore, type ResourceStoreOptions, type Result, type Row, type RowsQuery, createResourceHandlers, createResourceStore, drizzleRows, fieldJsonSchema, isFilterable, isSearchable, isSortable, openApiDocument, parseListQuery, prismaRows };
366
+ export { type AnyDatabase, type Authorize, type AuthorizeContext, type ChangeEvent, type ConstraintHit, type Failure, type FieldIssue, type ListQuery, type ListResult, type OpenApiOptions, type PrismaDelegate, ProblemCode, ProblemIssue, type QueryIssue, type ResourceAction, type ResourceHandlerOptions, type ResourceRows, type ResourceStore, type ResourceStoreOptions, type Result, type Row, type RowsQuery, createResourceHandlers, createResourceStore, drizzleRows, fieldJsonSchema, isFilterable, isSearchable, isSortable, openApiDocument, parseListQuery, prismaRows };