@flaredev/core 0.8.0 → 0.9.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.
@@ -15,6 +15,7 @@ var STRING_FORMATS = [
15
15
  "currency",
16
16
  "postcode"
17
17
  ];
18
+ var TEXT_FORMATS = ["markdown"];
18
19
  var NUMBER_FORMATS = ["money", "percent", "rating"];
19
20
  var FILE_CATEGORIES = {
20
21
  // For a field that holds whatever someone has: a drive, an attachment, a backup.
@@ -72,6 +73,12 @@ var field = {
72
73
  radio: (values, options) => build("enum", options, { options: values, widget: "radio" }),
73
74
  /** Any number of a list, picked with checkboxes. Stored as a JSON array. */
74
75
  multiselect: (values, options) => build("multiselect", options, { options: values, sortable: false }),
76
+ /** Free-text labels, as many as wanted. Stored as a JSON array. */
77
+ tags: (options) => build("tags", options, { sortable: false, searchable: false }),
78
+ /** Anything not table-shaped: a settings blob, a payload, metadata. Stored as JSON. */
79
+ json: (options) => build("json", options, { list: false, sortable: false, searchable: false }),
80
+ /** Long text written as Markdown: a split editor, rendered where it is shown. */
81
+ markdown: (options) => build("text", options, { format: "markdown", list: false, searchable: false }),
75
82
  email: (options) => build("string", options, { format: "email" }),
76
83
  url: (options) => build("string", options, { format: "url" }),
77
84
  tel: (options) => build("string", options, { format: "tel" }),
@@ -116,7 +123,7 @@ function pluralize(value) {
116
123
  }
117
124
 
118
125
  // src/resource/define.ts
119
- var RESERVED_FIELD_NAMES = ["id", "createdAt", "updatedAt"];
126
+ var RESERVED_FIELD_NAMES = ["id", "createdAt", "updatedAt", "deletedAt"];
120
127
  function clientResource(resource) {
121
128
  const { hooks, computed, $types, ...rest } = resource;
122
129
  void hooks;
@@ -195,6 +202,7 @@ function defineResource(config) {
195
202
  titleField,
196
203
  defaultSort,
197
204
  perPage,
205
+ softDelete: config.softDelete === true,
198
206
  fields,
199
207
  $types: void 0
200
208
  };
@@ -379,6 +387,19 @@ function fieldSchema(def) {
379
387
  if (def.maxItems !== void 0) s = s.refine((values) => values.length <= def.maxItems, `Choose at most ${def.maxItems}`);
380
388
  return s;
381
389
  }
390
+ case "tags": {
391
+ const maxLength = def.maxLength ?? 32;
392
+ let s = z.array(
393
+ z.string({ error: "Each tag is text" }).trim().min(1, "A tag can't be blank").max(maxLength, `A tag can be at most ${maxLength} characters`),
394
+ { error: typeError("Add one or more tags") }
395
+ ).refine((values) => new Set(values.map((value) => value.toLowerCase())).size === values.length, "Each tag can be added once");
396
+ const min = def.minItems ?? (def.required ? 1 : 0);
397
+ if (min > 0) s = s.refine((values) => values.length >= min, min === 1 ? "Add at least one tag" : `Add at least ${min} tags`);
398
+ if (def.maxItems !== void 0) s = s.refine((values) => values.length <= def.maxItems, `Add at most ${def.maxItems} tags`);
399
+ return s;
400
+ }
401
+ case "json":
402
+ return def.object ? z.record(z.string(), z.json(), { error: typeError("Enter a JSON object") }) : z.json();
382
403
  case "file":
383
404
  return z.string({ error: typeError("Upload a file") }).regex(OBJECT_KEY, "Invalid file");
384
405
  case "belongsTo":
@@ -412,6 +433,7 @@ function createValidators(resource) {
412
433
 
413
434
  // src/resource/policy.ts
414
435
  var ROLE = /^(\*|[a-z][a-z0-9_-]{0,31})$/;
436
+ var FIELD = /^[a-z][A-Za-z0-9]*$/;
415
437
  var PolicyError = class extends Error {
416
438
  constructor(message) {
417
439
  super(message);
@@ -429,7 +451,25 @@ function definePolicy(config) {
429
451
  }
430
452
  return [...new Set(roles)];
431
453
  };
432
- return { resource: config.resource, read: clean("read"), create: clean("create"), update: clean("update"), delete: clean("delete") };
454
+ const policy = {
455
+ resource: config.resource,
456
+ read: clean("read"),
457
+ create: clean("create"),
458
+ update: clean("update"),
459
+ delete: clean("delete")
460
+ };
461
+ if (config.own) {
462
+ const { field: field2, except = [] } = config.own;
463
+ if (!FIELD.test(field2)) {
464
+ throw new PolicyError(`Policy ${config.resource}.own: "${field2}" is not a field name (camelCase, starting with a lowercase letter).`);
465
+ }
466
+ for (const role of except) {
467
+ if (role === "*") throw new PolicyError(`Policy ${config.resource}.own.except: "*" exempts everyone, which is the same as leaving out \`own\`.`);
468
+ if (!ROLE.test(role)) throw new PolicyError(`Policy ${config.resource}.own.except: invalid role "${role}" (lowercase letters, digits, _ or -).`);
469
+ }
470
+ policy.own = { field: field2, except: [...new Set(except)] };
471
+ }
472
+ return policy;
433
473
  }
434
474
  function can(policy, role, action) {
435
475
  if (!policy) return true;
@@ -437,6 +477,12 @@ function can(policy, role, action) {
437
477
  if (allowed.includes("*")) return Boolean(role);
438
478
  return Boolean(role) && allowed.includes(role);
439
479
  }
480
+ function ownershipFilter(policy, user) {
481
+ const own = policy?.own;
482
+ if (!own || !user) return null;
483
+ if (user.role != null && own.except.includes(user.role)) return null;
484
+ return { field: own.field, value: user.id };
485
+ }
440
486
  function allowedActions(policy, role) {
441
487
  return {
442
488
  read: can(policy, role, "read"),
@@ -448,6 +494,7 @@ function allowedActions(policy, role) {
448
494
 
449
495
  export {
450
496
  STRING_FORMATS,
497
+ TEXT_FORMATS,
451
498
  NUMBER_FORMATS,
452
499
  FILE_CATEGORIES,
453
500
  mimeTypesFor,
@@ -487,5 +534,6 @@ export {
487
534
  PolicyError,
488
535
  definePolicy,
489
536
  can,
537
+ ownershipFilter,
490
538
  allowedActions
491
539
  };
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-dc9NbbgZ.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, N as NUMBER_FORMATS, k as NumberFormat, P as Policy, l as PolicyAction, m as PolicyConfig, n as PolicyError, o as RESERVED_FIELD_NAMES, p as RecordOf, q as ResourceConfig, r as ResourceDefinitionError, s as STRING_FORMATS, t as StringField, u as StringFormat, T as TextField, v as allowedActions, w as can, x as clientResource, y as columnName, z as defaultFieldLabel, A as definePolicy, G as defineResource, J as field, K as fileMaxBytes, L as mimeTypesFor, O as storedFields } from './policy-dc9NbbgZ.js';
1
+ import { F as Field, C as CreateInput, U as UpdateInput, R as Resource, S as StoredField } from './policy-DFimkxdx.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, J as JsonField, k as JsonOptions, M as MultiSelectField, N as NUMBER_FORMATS, l as NumberFormat, P as Policy, m as PolicyAction, n as PolicyConfig, o as PolicyError, p as PolicyOwnership, q as RESERVED_FIELD_NAMES, r as RecordOf, s as ResourceConfig, t as ResourceDefinitionError, u as STRING_FORMATS, v as StringField, w as StringFormat, T as TEXT_FORMATS, x as TagsField, y as TagsOptions, z as TextField, A as TextFormat, G as allowedActions, K as can, L as clientResource, O as columnName, Q as defaultFieldLabel, V as definePolicy, W as defineResource, X as field, Y as fileMaxBytes, Z as mimeTypesFor, _ as ownershipFilter, $ as storedFields } from './policy-DFimkxdx.js';
3
3
  import { z } from 'zod';
4
4
 
5
5
  /**
@@ -375,7 +375,7 @@ declare function formatValue(def: StoredField | {
375
375
 
376
376
  /**
377
377
  * Form state for a resource: strings for text-like inputs, booleans for toggles, and a
378
- * JSON array string for multiselects.
378
+ * JSON array string for multiselects and tags.
379
379
  * These helpers convert between records, form state, and API input so every admin
380
380
  * form handles empty values, numbers, and defaults the same way.
381
381
  */
@@ -388,7 +388,7 @@ declare function initialFormValues(resource: Pick<Resource, "fields">, record?:
388
388
  * empty required fields are left for validation to report as "Required".
389
389
  */
390
390
  declare function formValuesToInput(resource: Pick<Resource, "fields">, values: FormValues, mode: "create" | "edit"): Record<string, unknown>;
391
- /** A multiselect's form state (a JSON array string) as the picked values. */
391
+ /** A multiselect's or tags field's form state (a JSON array string) as the picked values. */
392
392
  declare function parseMultiValue(raw: string | boolean | undefined): string[];
393
393
  /** Field errors keyed by field name, from validation or API issues (first message wins). */
394
394
  declare function issuesByField(issues: {
@@ -529,6 +529,6 @@ interface SeedContext {
529
529
  type Seed = (context: SeedContext) => void | Promise<void>;
530
530
  declare function defineSeed(seed: Seed): Seed;
531
531
 
532
- declare const FLARE_VERSION = "0.8.0";
532
+ declare const FLARE_VERSION = "0.9.0";
533
533
 
534
534
  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
@@ -6,6 +6,7 @@ import {
6
6
  RESERVED_FIELD_NAMES,
7
7
  ResourceDefinitionError,
8
8
  STRING_FORMATS,
9
+ TEXT_FORMATS,
9
10
  allowedActions,
10
11
  camelCase,
11
12
  can,
@@ -32,6 +33,7 @@ import {
32
33
  kebabCase,
33
34
  mimeTypesFor,
34
35
  normalizeDomain,
36
+ ownershipFilter,
35
37
  pascalCase,
36
38
  pluralize,
37
39
  slugify,
@@ -40,7 +42,7 @@ import {
40
42
  storedFields,
41
43
  toE164,
42
44
  words
43
- } from "./chunk-JYWV3AF6.js";
45
+ } from "./chunk-AAXNSIZG.js";
44
46
 
45
47
  // src/password.ts
46
48
  var ALGORITHM = "pbkdf2-sha256";
@@ -605,6 +607,13 @@ function formatValue(def, value, options = {}) {
605
607
  return optionLabel(def, String(value));
606
608
  case "multiselect":
607
609
  return Array.isArray(value) && value.length ? value.map((item) => optionLabel(def, String(item))).join(", ") : "\u2014";
610
+ case "tags":
611
+ return Array.isArray(value) && value.length ? value.map(String).join(", ") : "\u2014";
612
+ case "json": {
613
+ if (value === null || value === void 0) return "\u2014";
614
+ const text2 = typeof value === "string" ? value : JSON.stringify(value);
615
+ return text2.length > 60 ? `${text2.slice(0, 59)}\u2026` : text2;
616
+ }
608
617
  case "string":
609
618
  if (def.format === "tel") return formatPhone(String(value));
610
619
  if (def.format === "country") return `${flagEmoji(String(value))} ${countryName(String(value), locale.split("-")[0])}`.trim();
@@ -622,7 +631,8 @@ function initialFormValues(resource, record) {
622
631
  for (const [key, def] of storedFields(resource)) {
623
632
  const source = record ? record[key] : "default" in def ? def.default : void 0;
624
633
  if (def.kind === "boolean") values[key] = source === true || source === 1;
625
- else if (def.kind === "multiselect") values[key] = JSON.stringify(Array.isArray(source) ? source : []);
634
+ else if (def.kind === "multiselect" || def.kind === "tags") values[key] = JSON.stringify(Array.isArray(source) ? source : []);
635
+ else if (def.kind === "json") values[key] = source === null || source === void 0 ? "" : JSON.stringify(source, null, 2);
626
636
  else if (source === null || source === void 0) values[key] = "";
627
637
  else if (source instanceof Date) values[key] = source.toISOString();
628
638
  else values[key] = String(source);
@@ -637,12 +647,26 @@ function formValuesToInput(resource, values, mode) {
637
647
  input[key] = raw === true;
638
648
  continue;
639
649
  }
640
- if (def.kind === "multiselect") {
650
+ if (def.kind === "multiselect" || def.kind === "tags") {
641
651
  const picked = parseMultiValue(raw);
642
652
  if (picked.length === 0 && mode === "create" && "default" in def && def.default !== void 0) continue;
643
653
  input[key] = picked.length === 0 && !def.required ? null : picked;
644
654
  continue;
645
655
  }
656
+ if (def.kind === "json") {
657
+ const source = typeof raw === "string" ? raw.trim() : "";
658
+ if (source === "") {
659
+ if (mode === "create" && "default" in def && def.default !== void 0) continue;
660
+ if (!def.required) input[key] = null;
661
+ continue;
662
+ }
663
+ try {
664
+ input[key] = JSON.parse(source);
665
+ } catch {
666
+ input[key] = Number.NaN;
667
+ }
668
+ continue;
669
+ }
646
670
  const text2 = typeof raw === "string" ? raw : "";
647
671
  const empty = text2.trim() === "";
648
672
  if (empty) {
@@ -995,7 +1019,7 @@ function createFake(seed = 20260101) {
995
1019
  var fake = createFake();
996
1020
 
997
1021
  // src/index.ts
998
- var FLARE_VERSION = "0.8.0";
1022
+ var FLARE_VERSION = "0.9.0";
999
1023
  export {
1000
1024
  DEFAULT_FILE_MAX_BYTES,
1001
1025
  FILE_CATEGORIES,
@@ -1009,6 +1033,7 @@ export {
1009
1033
  SNIFF_BYTES,
1010
1034
  STRING_FORMATS,
1011
1035
  SUBSCRIPTION_STATUSES,
1036
+ TEXT_FORMATS,
1012
1037
  allowedActions,
1013
1038
  camelCase,
1014
1039
  can,
@@ -1053,6 +1078,7 @@ export {
1053
1078
  mimeTypesFor,
1054
1079
  normalizeDomain,
1055
1080
  optionLabel,
1081
+ ownershipFilter,
1056
1082
  parseMultiValue,
1057
1083
  pascalCase,
1058
1084
  pluralize,
@@ -6,7 +6,7 @@
6
6
  * rule in a descriptor takes effect without re-running the generator. Only
7
7
  * storage-level changes (kind, nullability, uniqueness, relations) need a migration.
8
8
  */
9
- type FieldKind = "string" | "text" | "int" | "float" | "boolean" | "date" | "datetime" | "enum" | "multiselect" | "file" | "belongsTo" | "hasMany";
9
+ type FieldKind = "string" | "text" | "int" | "float" | "boolean" | "date" | "datetime" | "enum" | "multiselect" | "tags" | "json" | "file" | "belongsTo" | "hasMany";
10
10
  /** Options shared by every stored (non-hasMany) field. */
11
11
  interface CommonOptions<T> {
12
12
  label?: string;
@@ -50,9 +50,49 @@ interface StringOptions extends CommonOptions<string> {
50
50
  format?: StringFormat;
51
51
  pattern?: string;
52
52
  }
53
+ /**
54
+ * What long text holds.
55
+ *
56
+ * The column is `text` either way. `markdown` changes the editor — a split write/preview
57
+ * pane rather than a plain textarea — and how the value is rendered. Nothing is escaped
58
+ * or sanitised on the way in: it is stored exactly as typed, and rendered as Markdown
59
+ * where the dashboard shows it.
60
+ */
61
+ type TextFormat = "markdown";
62
+ declare const TEXT_FORMATS: readonly TextFormat[];
53
63
  interface TextOptions extends CommonOptions<string> {
54
64
  maxLength?: number;
55
65
  minLength?: number;
66
+ format?: TextFormat;
67
+ }
68
+ /**
69
+ * Free-text labels, as many as you like.
70
+ *
71
+ * `multiselect` is the one to reach for when the vocabulary is fixed and known — it
72
+ * validates against its options and renders a picker. `tags` is for the other case: the
73
+ * person typing decides what the values are. Stored as a JSON array, like a multiselect,
74
+ * so a row holds `["urgent", "q3"]` rather than a comma-joined string nobody can query.
75
+ */
76
+ interface TagsOptions extends Omit<CommonOptions<string[]>, "unique"> {
77
+ /** Fewest / most tags. A required field needs at least one. */
78
+ minItems?: number;
79
+ maxItems?: number;
80
+ /** Longest a single tag may be. Default 32. */
81
+ maxLength?: number;
82
+ /** Shown in the input before anything is typed, e.g. "add a label". */
83
+ placeholder?: string;
84
+ }
85
+ /**
86
+ * Anything that is not table-shaped: a settings blob, a webhook payload, metadata.
87
+ *
88
+ * Stored as JSON — a `text` column on SQLite, `jsonb` on Postgres. Validated as parseable
89
+ * and nothing more, because the point of the field is that its shape is not declared. If
90
+ * you find yourself reaching for it to hold something with a known shape, that shape wants
91
+ * fields of its own, or a resource of its own.
92
+ */
93
+ interface JsonOptions extends Omit<CommonOptions<unknown>, "unique"> {
94
+ /** Reject anything that is not a JSON object, for a settings blob. Default false. */
95
+ object?: boolean;
56
96
  }
57
97
  /**
58
98
  * What a number means, which decides how it is shown and entered.
@@ -121,6 +161,8 @@ type EnumField<O extends string = string, R extends boolean = boolean> = Base<"e
121
161
  type MultiSelectField<O extends string = string, R extends boolean = boolean> = Base<"multiselect", R> & MultiSelectOptions<O> & {
122
162
  options: readonly O[];
123
163
  };
164
+ type TagsField<R extends boolean = boolean> = Base<"tags", R> & TagsOptions;
165
+ type JsonField<R extends boolean = boolean> = Base<"json", R> & JsonOptions;
124
166
  type FileField<R extends boolean = boolean> = Base<"file", R> & FileOptions & {
125
167
  /** Accepted categories from the `file:[image,pdf]` grammar. */
126
168
  accept: readonly FileCategory[];
@@ -134,7 +176,7 @@ type HasManyField = {
134
176
  required: false;
135
177
  target: string;
136
178
  } & HasManyOptions;
137
- type Field = StringField | TextField | IntField | FloatField | BooleanField | DateField | DateTimeField | EnumField | MultiSelectField | FileField | BelongsToField | HasManyField;
179
+ type Field = StringField | TextField | IntField | FloatField | BooleanField | DateField | DateTimeField | EnumField | MultiSelectField | TagsField | JsonField | FileField | BelongsToField | HasManyField;
138
180
  type StoredField = Exclude<Field, HasManyField>;
139
181
  /** File categories usable in `file:[...]` and the MIME types each allows. */
140
182
  declare const FILE_CATEGORIES: {
@@ -172,6 +214,12 @@ declare const field: {
172
214
  radio: <const V extends readonly [string, ...string[]], const O extends EnumOptions<V[number]>>(values: V, options?: O) => EnumField<V[number], Req<O>> & O;
173
215
  /** Any number of a list, picked with checkboxes. Stored as a JSON array. */
174
216
  multiselect: <const V extends readonly [string, ...string[]], const O extends MultiSelectOptions<V[number]>>(values: V, options?: O) => MultiSelectField<V[number], Req<O>> & O;
217
+ /** Free-text labels, as many as wanted. Stored as a JSON array. */
218
+ tags: <const O extends TagsOptions>(options?: O) => TagsField<Req<O>> & O;
219
+ /** Anything not table-shaped: a settings blob, a payload, metadata. Stored as JSON. */
220
+ json: <const O extends JsonOptions>(options?: O) => JsonField<Req<O>> & O;
221
+ /** Long text written as Markdown: a split editor, rendered where it is shown. */
222
+ markdown: <const O extends Omit<TextOptions, "format">>(options?: O) => TextField<Req<O>> & O;
175
223
  email: <const O extends Omit<StringOptions, "format">>(options?: O) => StringField<Req<O>> & O;
176
224
  url: <const O extends Omit<StringOptions, "format">>(options?: O) => StringField<Req<O>> & O;
177
225
  tel: <const O extends Omit<StringOptions, "format">>(options?: O) => StringField<Req<O>> & O;
@@ -232,7 +280,7 @@ type RecordOf<Fields> = Simplify<{
232
280
  } ? FieldValue<Fields[K]> : FieldValue<Fields[K]> | null;
233
281
  }>;
234
282
 
235
- declare const RESERVED_FIELD_NAMES: readonly ["id", "createdAt", "updatedAt"];
283
+ declare const RESERVED_FIELD_NAMES: readonly ["id", "createdAt", "updatedAt", "deletedAt"];
236
284
  type FieldKey<Fields> = Extract<keyof Fields, string>;
237
285
  interface ResourceConfig<Fields extends Record<string, Field>> {
238
286
  /** PascalCase singular name, e.g. "Contact", "OrderItem". */
@@ -259,6 +307,20 @@ interface ResourceConfig<Fields extends Record<string, Field>> {
259
307
  };
260
308
  /** Page size for list endpoints and tables. Default 25. */
261
309
  perPage?: number;
310
+ /**
311
+ * Keep deleted records instead of removing them.
312
+ *
313
+ * Adds a nullable `deletedAt` column. A delete stamps it; every read leaves those rows
314
+ * out, so nothing else in the app has to know. The dashboard grows a Trash view with
315
+ * Restore and Delete forever, and `?deleted=only` lists them over the API.
316
+ *
317
+ * Two things it does not change, both worth knowing before switching it on. A unique
318
+ * value is still taken by a deleted row — an email freed by deleting a user cannot be
319
+ * signed up again until the row is really gone. And a foreign key still points at it, so
320
+ * a deleted parent keeps its children valid, which is usually what you wanted but is a
321
+ * decision rather than an accident.
322
+ */
323
+ softDelete?: boolean;
262
324
  /**
263
325
  * Your logic, beside the fields it belongs to. Every write — the REST API, the
264
326
  * dashboard's forms, a seed, an import — goes through the same store, so a hook here
@@ -323,6 +385,8 @@ interface Resource<Fields extends Record<string, Field> = Record<string, Field>>
323
385
  direction: "asc" | "desc";
324
386
  };
325
387
  perPage: number;
388
+ /** Deleted records are kept and hidden, not removed. See ResourceConfig.softDelete. */
389
+ softDelete: boolean;
326
390
  /** Fields with labels filled in. */
327
391
  fields: Fields & Record<string, Field & {
328
392
  label: string;
@@ -364,13 +428,33 @@ declare function storedFields(resource: Pick<Resource, "fields">): [string, Stor
364
428
  declare const columnName: (key: string) => string;
365
429
 
366
430
  /**
367
- * Resource-level policies: which roles may read, create, update, or delete a resource.
431
+ * Resource-level policies: which roles may read, create, update, or delete a resource,
432
+ * and — with `own` — which rows each user may touch.
368
433
  *
369
434
  * Policies are plain data, like descriptors, so the API layer and the admin UI read the
370
- * same rules at runtime. Field-level rules and per-record ownership are deliberately out
371
- * of scope for v1.
435
+ * same rules at runtime. That is why ownership is a field name and a list of exempt
436
+ * roles rather than a callback: it can be read, printed and reasoned about without being
437
+ * run, and it crosses to the dashboard unchanged. Field-level rules are still out of
438
+ * scope.
439
+ *
440
+ * Roles and ownership answer different questions and compose. The role decides whether
441
+ * you may list invoices at all; ownership decides which invoices "all of them" means.
372
442
  */
373
443
  type PolicyAction = "list" | "read" | "create" | "update" | "delete";
444
+ /**
445
+ * Per-record ownership: each user sees and changes only the rows that are theirs.
446
+ *
447
+ * `field` is a field on the resource holding the owning user's id — normally a
448
+ * `belongsTo("User")`. The store fills it in on create, requires it to match on read,
449
+ * update and delete, and adds it to every list query, so a row that isn't yours is a
450
+ * 404 rather than a 403: a user who can't see a record shouldn't learn it exists.
451
+ */
452
+ interface PolicyOwnership {
453
+ /** The field holding the owning user's id, e.g. "userId". */
454
+ field: string;
455
+ /** Roles that see and change every row regardless — usually `["admin"]`. */
456
+ except: string[];
457
+ }
374
458
  /** Roles allowed per action; `"*"` means any signed-in user. */
375
459
  interface Policy {
376
460
  resource: string;
@@ -378,6 +462,8 @@ interface Policy {
378
462
  create: string[];
379
463
  update: string[];
380
464
  delete: string[];
465
+ /** Restrict every action to the user's own rows. Absent means no restriction. */
466
+ own?: PolicyOwnership;
381
467
  }
382
468
  interface PolicyConfig {
383
469
  resource: string;
@@ -385,6 +471,16 @@ interface PolicyConfig {
385
471
  create?: string[];
386
472
  update?: string[];
387
473
  delete?: string[];
474
+ /**
475
+ * Restrict every action to the user's own rows.
476
+ *
477
+ * `{ field: "userId" }` is the common case: `except` defaults to none, so even an
478
+ * admin sees only their own unless you say otherwise.
479
+ */
480
+ own?: {
481
+ field: string;
482
+ except?: string[];
483
+ };
388
484
  }
389
485
  declare class PolicyError extends Error {
390
486
  constructor(message: string);
@@ -396,7 +492,20 @@ declare function definePolicy(config: PolicyConfig): Policy;
396
492
  * `flare gen policy <Resource> --roles …` to restrict a resource.
397
493
  */
398
494
  declare function can(policy: Policy | undefined | null, role: string | null | undefined, action: PolicyAction): boolean;
495
+ /**
496
+ * The rows this user is limited to, or null when they may touch every row.
497
+ *
498
+ * Null for three different reasons — no policy, no `own`, or a role in `except` — and
499
+ * the caller treats them the same, because they mean the same thing.
500
+ */
501
+ declare function ownershipFilter(policy: Policy | undefined | null, user: {
502
+ id: string;
503
+ role?: string | null;
504
+ } | null | undefined): {
505
+ field: string;
506
+ value: string;
507
+ } | null;
399
508
  /** Actions `role` may perform, for hiding UI it can't use. */
400
509
  declare function allowedActions(policy: Policy | undefined | null, role: string | null | undefined): Record<Exclude<PolicyAction, "list">, boolean>;
401
510
 
402
- export { definePolicy as A, type BelongsToField as B, type CreateInput as C, DEFAULT_FILE_MAX_BYTES as D, type EnumField as E, type Field as F, defineResource as G, type HasManyField as H, type IntField as I, field as J, fileMaxBytes as K, mimeTypesFor as L, type MultiSelectField as M, NUMBER_FORMATS as N, storedFields as O, 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 NumberFormat as k, type PolicyAction as l, type PolicyConfig as m, PolicyError as n, RESERVED_FIELD_NAMES as o, type RecordOf as p, type ResourceConfig as q, ResourceDefinitionError as r, STRING_FORMATS as s, type StringField as t, type StringFormat as u, allowedActions as v, can as w, clientResource as x, columnName as y, defaultFieldLabel as z };
511
+ export { storedFields as $, type TextFormat as A, type BelongsToField as B, type CreateInput as C, DEFAULT_FILE_MAX_BYTES as D, type EnumField as E, type Field as F, allowedActions as G, type HasManyField as H, type IntField as I, type JsonField as J, can as K, clientResource as L, type MultiSelectField as M, NUMBER_FORMATS as N, columnName as O, type Policy as P, defaultFieldLabel as Q, type Resource as R, type StoredField as S, TEXT_FORMATS as T, type UpdateInput as U, definePolicy as V, defineResource as W, field as X, fileMaxBytes as Y, mimeTypesFor as Z, ownershipFilter as _, 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 JsonOptions as k, type NumberFormat as l, type PolicyAction as m, type PolicyConfig as n, PolicyError as o, type PolicyOwnership as p, RESERVED_FIELD_NAMES as q, type RecordOf as r, type ResourceConfig as s, ResourceDefinitionError as t, STRING_FORMATS as u, type StringField as v, type StringFormat as w, type TagsField as x, type TagsOptions as y, type TextField 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, SQLiteColumn } from 'drizzle-orm/sqlite-core';
3
- import { S as StoredField, R as Resource, F as Field, P as Policy } from './policy-dc9NbbgZ.js';
3
+ import { S as StoredField, R as Resource, F as Field, P as Policy } from './policy-DFimkxdx.js';
4
4
 
5
5
  /**
6
6
  * The handful of things a resource store needs a database to do.
@@ -122,7 +122,18 @@ declare function isSearchable(def: StoredField): boolean;
122
122
  * resource. Only fields the descriptor marks sortable/filterable/searchable are
123
123
  * accepted, so clients can't probe arbitrary columns.
124
124
  */
125
- declare function parseListQuery(resource: Resource, params: URLSearchParams): {
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
+ }): {
126
137
  query: ListQuery;
127
138
  } | {
128
139
  issues: QueryIssue[];
@@ -215,7 +226,9 @@ declare function createResourceStore(options: ResourceStoreOptions): {
215
226
  resource: Resource<Record<string, Field>>;
216
227
  table: SQLiteTable<drizzle_orm_sqlite_core.TableConfig> | undefined;
217
228
  /** `params`: page, perPage, sort, q, filter[field], cursor (as parsed by parseListQuery). */
218
- list(params: URLSearchParams): Promise<Result<ListResult>>;
229
+ list(params: URLSearchParams, options?: {
230
+ maxPerPage?: number;
231
+ }): Promise<Result<ListResult>>;
219
232
  get(id: string): Promise<Result<Record<string, unknown>>>;
220
233
  /** Title-field values for a set of ids (for showing relations). Missing ids are omitted. */
221
234
  titles(ids: string[]): Promise<Record<string, string>>;
@@ -271,7 +284,9 @@ declare function createResourceHandlers(options: ResourceHandlerOptions): {
271
284
  store: {
272
285
  resource: Resource<Record<string, Field>>;
273
286
  table: SQLiteTable<drizzle_orm_sqlite_core.TableConfig> | undefined;
274
- list(params: URLSearchParams): Promise<Result<ListResult>>;
287
+ list(params: URLSearchParams, options?: {
288
+ maxPerPage?: number;
289
+ }): Promise<Result<ListResult>>;
275
290
  get(id: string): Promise<Result<Record<string, unknown>>>;
276
291
  titles(ids: string[]): Promise<Record<string, string>>;
277
292
  create(raw: unknown): Promise<Result<Record<string, unknown>>>;
package/dist/server.js CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  columnName,
4
4
  createValidators,
5
5
  storedFields
6
- } from "./chunk-JYWV3AF6.js";
6
+ } from "./chunk-AAXNSIZG.js";
7
7
 
8
8
  // ../../node_modules/.pnpm/drizzle-orm@0.45.2_@cloudfl_21866ab18c10d2d988b916e06105fbce/node_modules/drizzle-orm/entity.js
9
9
  var entityKind = /* @__PURE__ */ Symbol.for("drizzle:entityKind");
@@ -1319,7 +1319,7 @@ function coerce(def, raw) {
1319
1319
  return raw;
1320
1320
  }
1321
1321
  }
1322
- function parseListQuery(resource, params) {
1322
+ function parseListQuery(resource, params, options = {}) {
1323
1323
  const issues = [];
1324
1324
  const fields = new Map(storedFields(resource));
1325
1325
  const positiveInt = (name, fallback, max = Number.MAX_SAFE_INTEGER) => {
@@ -1333,7 +1333,7 @@ function parseListQuery(resource, params) {
1333
1333
  return value;
1334
1334
  };
1335
1335
  const page = positiveInt("page", 1);
1336
- const perPage = positiveInt("perPage", resource.perPage, MAX_PER_PAGE);
1336
+ const perPage = positiveInt("perPage", resource.perPage, options.maxPerPage ?? MAX_PER_PAGE);
1337
1337
  let sort = resource.defaultSort;
1338
1338
  const rawSort = params.get("sort");
1339
1339
  if (rawSort) {
@@ -1449,8 +1449,8 @@ function createResourceStore(options) {
1449
1449
  resource,
1450
1450
  table,
1451
1451
  /** `params`: page, perPage, sort, q, filter[field], cursor (as parsed by parseListQuery). */
1452
- async list(params) {
1453
- const parsed = parseListQuery(resource, params);
1452
+ async list(params, options2 = {}) {
1453
+ const parsed = parseListQuery(resource, params, options2);
1454
1454
  if ("issues" in parsed) return fail(400, "Invalid query.", { queryIssues: parsed.issues });
1455
1455
  const { page, perPage, sort, q, filters, cursor } = parsed.query;
1456
1456
  const isDateField = (key) => {
@@ -1799,6 +1799,20 @@ function fieldJsonSchema(def) {
1799
1799
  if (def.maxItems !== void 0) schema.maxItems = def.maxItems;
1800
1800
  return schema;
1801
1801
  }
1802
+ case "tags": {
1803
+ const schema = {
1804
+ ...base,
1805
+ type: "array",
1806
+ items: { type: "string", maxLength: def.maxLength ?? 32 },
1807
+ uniqueItems: true,
1808
+ examples: [["urgent", "q3"]]
1809
+ };
1810
+ if (def.minItems !== void 0) schema.minItems = def.minItems;
1811
+ if (def.maxItems !== void 0) schema.maxItems = def.maxItems;
1812
+ return schema;
1813
+ }
1814
+ case "json":
1815
+ return def.object ? { ...base, type: "object", description: base.description ?? "A JSON object." } : { ...base, description: base.description ?? "Any JSON value." };
1802
1816
  case "file":
1803
1817
  return { ...base, type: "string", description: base.description ?? "Storage key of an uploaded file." };
1804
1818
  case "belongsTo":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flaredev/core",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Flare runtime: resource descriptors, field grammar, and Cloudflare-native helpers",
5
5
  "license": "MIT",
6
6
  "author": "Muke Johnbaptist",