@flaredev/core 0.1.2 → 0.3.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.
- package/dist/chunk-434JXSDV.js +410 -0
- package/dist/index.d.ts +109 -35
- package/dist/index.js +352 -80
- package/dist/{define-D5CtCkOm.d.ts → policy-BqRg9RVM.d.ts} +80 -4
- package/dist/server.d.ts +29 -2
- package/dist/server.js +273 -7
- package/package.json +2 -1
- package/dist/chunk-DARDHZPX.js +0 -192
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
// src/resource/fields.ts
|
|
2
|
+
var STRING_FORMATS = ["email", "url", "tel", "domain", "country", "color", "slug"];
|
|
3
|
+
var FILE_CATEGORIES = {
|
|
4
|
+
image: ["image/png", "image/jpeg", "image/gif", "image/webp", "image/avif"],
|
|
5
|
+
pdf: ["application/pdf"],
|
|
6
|
+
video: ["video/*"],
|
|
7
|
+
audio: ["audio/*"],
|
|
8
|
+
text: ["text/plain"],
|
|
9
|
+
csv: ["text/csv"],
|
|
10
|
+
document: [
|
|
11
|
+
"application/msword",
|
|
12
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
13
|
+
"application/vnd.oasis.opendocument.text"
|
|
14
|
+
],
|
|
15
|
+
spreadsheet: [
|
|
16
|
+
"application/vnd.ms-excel",
|
|
17
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
18
|
+
"application/vnd.oasis.opendocument.spreadsheet"
|
|
19
|
+
],
|
|
20
|
+
archive: ["application/zip"]
|
|
21
|
+
};
|
|
22
|
+
function mimeTypesFor(accept) {
|
|
23
|
+
return [...new Set(accept.flatMap((category) => FILE_CATEGORIES[category]))];
|
|
24
|
+
}
|
|
25
|
+
var DEFAULT_FILE_MAX_BYTES = 10 * 1024 * 1024;
|
|
26
|
+
var fileMaxBytes = (field2) => field2.maxBytes ?? DEFAULT_FILE_MAX_BYTES;
|
|
27
|
+
function build(kind, options, extra = {}) {
|
|
28
|
+
return { kind, ...extra, ...options, required: options?.required !== false };
|
|
29
|
+
}
|
|
30
|
+
var field = {
|
|
31
|
+
string: (options) => build("string", options),
|
|
32
|
+
text: (options) => build("text", options, { list: false, searchable: false }),
|
|
33
|
+
int: (options) => build("int", options),
|
|
34
|
+
float: (options) => build("float", options),
|
|
35
|
+
boolean: (options) => build("boolean", options),
|
|
36
|
+
date: (options) => build("date", options),
|
|
37
|
+
datetime: (options) => build("datetime", options),
|
|
38
|
+
enum: (values, options) => build("enum", options, { options: values }),
|
|
39
|
+
/** One of a list, picked from a dropdown. The same as `enum`. */
|
|
40
|
+
select: (values, options) => build("enum", options, { options: values, widget: "select" }),
|
|
41
|
+
/** One of a list, picked with radio buttons. Stored like `enum`. */
|
|
42
|
+
radio: (values, options) => build("enum", options, { options: values, widget: "radio" }),
|
|
43
|
+
/** Any number of a list, picked with checkboxes. Stored as a JSON array. */
|
|
44
|
+
multiselect: (values, options) => build("multiselect", options, { options: values, sortable: false }),
|
|
45
|
+
email: (options) => build("string", options, { format: "email" }),
|
|
46
|
+
url: (options) => build("string", options, { format: "url" }),
|
|
47
|
+
tel: (options) => build("string", options, { format: "tel" }),
|
|
48
|
+
domain: (options) => build("string", options, { format: "domain" }),
|
|
49
|
+
country: (options) => build("string", options, { format: "country" }),
|
|
50
|
+
color: (options) => build("string", options, { format: "color" }),
|
|
51
|
+
slug: (options) => build("string", options, { format: "slug" }),
|
|
52
|
+
file: (accept, options) => build("file", options, { accept, list: false, sortable: false }),
|
|
53
|
+
belongsTo: (target, options) => build("belongsTo", options, { target }),
|
|
54
|
+
hasMany: (target, options) => ({ kind: "hasMany", target, ...options, required: false })
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// src/resource/naming.ts
|
|
58
|
+
function words(value) {
|
|
59
|
+
return value.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2").split(/[^A-Za-z0-9]+/).filter(Boolean).map((word) => word.toLowerCase());
|
|
60
|
+
}
|
|
61
|
+
var pascalCase = (value) => words(value).map((w) => w[0].toUpperCase() + w.slice(1)).join("");
|
|
62
|
+
var camelCase = (value) => {
|
|
63
|
+
const pascal = pascalCase(value);
|
|
64
|
+
return pascal ? pascal[0].toLowerCase() + pascal.slice(1) : pascal;
|
|
65
|
+
};
|
|
66
|
+
var snakeCase = (value) => words(value).join("_");
|
|
67
|
+
var kebabCase = (value) => words(value).join("-");
|
|
68
|
+
var humanize = (value) => {
|
|
69
|
+
const text = words(value).join(" ");
|
|
70
|
+
return text ? text[0].toUpperCase() + text.slice(1) : text;
|
|
71
|
+
};
|
|
72
|
+
var UNCOUNTABLE = /* @__PURE__ */ new Set(["equipment", "information", "rice", "money", "species", "series", "fish", "sheep", "data", "news"]);
|
|
73
|
+
var IRREGULAR = { person: "people", man: "men", woman: "women", child: "children", mouse: "mice" };
|
|
74
|
+
function pluralize(value) {
|
|
75
|
+
const match = /([A-Za-z]+)$/.exec(value);
|
|
76
|
+
if (!match) return value;
|
|
77
|
+
const word = match[1];
|
|
78
|
+
const lower = word.toLowerCase();
|
|
79
|
+
const head = value.slice(0, value.length - word.length);
|
|
80
|
+
const keepCase = (plural) => word[0] === word[0].toUpperCase() ? plural[0].toUpperCase() + plural.slice(1) : plural;
|
|
81
|
+
if (UNCOUNTABLE.has(lower)) return value;
|
|
82
|
+
if (IRREGULAR[lower]) return head + keepCase(IRREGULAR[lower]);
|
|
83
|
+
if (/[^aeiou]y$/i.test(word)) return head + word.slice(0, -1) + "ies";
|
|
84
|
+
if (/(s|x|z|ch|sh)$/i.test(word)) return head + word + "es";
|
|
85
|
+
return head + word + "s";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// src/resource/define.ts
|
|
89
|
+
var RESERVED_FIELD_NAMES = ["id", "createdAt", "updatedAt"];
|
|
90
|
+
var ResourceDefinitionError = class extends Error {
|
|
91
|
+
constructor(resource, message) {
|
|
92
|
+
super(`Resource "${resource}": ${message}`);
|
|
93
|
+
this.name = "ResourceDefinitionError";
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
var IDENTIFIER = /^[a-z][A-Za-z0-9]*$/;
|
|
97
|
+
function defaultFieldLabel(key, field2) {
|
|
98
|
+
return humanize(field2.kind === "belongsTo" ? key.replace(/Id$/, "") : key);
|
|
99
|
+
}
|
|
100
|
+
function defineResource(config) {
|
|
101
|
+
const name = config.name;
|
|
102
|
+
const fail = (message) => {
|
|
103
|
+
throw new ResourceDefinitionError(name, message);
|
|
104
|
+
};
|
|
105
|
+
if (!/^[A-Z][A-Za-z0-9]*$/.test(name) || pascalCase(name) !== name) fail('name must be PascalCase, e.g. "OrderItem".');
|
|
106
|
+
const entries = Object.entries(config.fields);
|
|
107
|
+
if (entries.length === 0) fail("define at least one field.");
|
|
108
|
+
const fields = {};
|
|
109
|
+
for (const [key, def] of entries) {
|
|
110
|
+
if (!IDENTIFIER.test(key)) fail(`field "${key}" must be camelCase (letters and digits, starting lowercase).`);
|
|
111
|
+
if (RESERVED_FIELD_NAMES.includes(key)) fail(`"${key}" is added automatically; don't declare it.`);
|
|
112
|
+
if (def.kind === "enum") {
|
|
113
|
+
if (def.options.length === 0) fail(`enum field "${key}" needs at least one option.`);
|
|
114
|
+
if (new Set(def.options).size !== def.options.length) fail(`enum field "${key}" has duplicate options.`);
|
|
115
|
+
if (def.default !== void 0 && !def.options.includes(def.default)) fail(`default of "${key}" isn't one of its options.`);
|
|
116
|
+
}
|
|
117
|
+
if (def.kind === "multiselect") {
|
|
118
|
+
if (def.options.length === 0) fail(`multiselect field "${key}" needs at least one option.`);
|
|
119
|
+
if (new Set(def.options).size !== def.options.length) fail(`multiselect field "${key}" has duplicate options.`);
|
|
120
|
+
if (def.default?.some((value) => !def.options.includes(value))) fail(`default of "${key}" includes a value that isn't one of its options.`);
|
|
121
|
+
if (def.minItems !== void 0 && def.maxItems !== void 0 && def.minItems > def.maxItems) fail(`"${key}": minItems is larger than maxItems.`);
|
|
122
|
+
}
|
|
123
|
+
if (def.kind === "string" && def.format && !STRING_FORMATS.includes(def.format)) {
|
|
124
|
+
fail(`field "${key}" has unknown format "${def.format}". Formats: ${STRING_FORMATS.join(", ")}.`);
|
|
125
|
+
}
|
|
126
|
+
if (def.kind === "belongsTo" && !key.endsWith("Id")) fail(`belongsTo field "${key}" must end in "Id", e.g. "${camelCase(def.target)}Id".`);
|
|
127
|
+
if ((def.kind === "belongsTo" || def.kind === "hasMany") && pascalCase(def.target) !== def.target) {
|
|
128
|
+
fail(`relation "${key}" must target a PascalCase resource name.`);
|
|
129
|
+
}
|
|
130
|
+
if (def.kind === "belongsTo" && def.onDelete === "set null" && def.required) {
|
|
131
|
+
fail(`"${key}" uses onDelete "set null" but is required; mark it required: false.`);
|
|
132
|
+
}
|
|
133
|
+
if (def.kind === "file" && def.accept.length === 0) fail(`file field "${key}" must accept at least one category.`);
|
|
134
|
+
fields[key] = { ...def, label: def.label ?? defaultFieldLabel(key, def) };
|
|
135
|
+
}
|
|
136
|
+
const stored = entries.filter(([, def]) => def.kind !== "hasMany");
|
|
137
|
+
const titleField = config.titleField ?? stored.find(([, def]) => def.kind === "string" && !["tel", "country", "color"].includes(def.format ?? ""))?.[0] ?? stored.find(([, def]) => def.kind === "string")?.[0] ?? stored[0]?.[0] ?? "id";
|
|
138
|
+
if (config.titleField && !stored.some(([key]) => key === config.titleField)) {
|
|
139
|
+
fail(`titleField "${config.titleField}" isn't a stored field.`);
|
|
140
|
+
}
|
|
141
|
+
const defaultSort = config.defaultSort ?? { field: "createdAt", direction: "desc" };
|
|
142
|
+
if (!["createdAt", "updatedAt"].includes(defaultSort.field) && !stored.some(([key]) => key === defaultSort.field)) {
|
|
143
|
+
fail(`defaultSort field "${defaultSort.field}" isn't a stored field.`);
|
|
144
|
+
}
|
|
145
|
+
const perPage = config.perPage ?? 25;
|
|
146
|
+
if (!Number.isInteger(perPage) || perPage < 1 || perPage > 100) fail("perPage must be an integer from 1 to 100.");
|
|
147
|
+
const pluralName = pluralize(name);
|
|
148
|
+
return {
|
|
149
|
+
name,
|
|
150
|
+
table: config.table ?? snakeCase(pluralName),
|
|
151
|
+
slug: config.slug ?? kebabCase(pluralName),
|
|
152
|
+
label: config.label ?? humanize(name),
|
|
153
|
+
pluralLabel: config.pluralLabel ?? humanize(pluralName),
|
|
154
|
+
icon: config.icon,
|
|
155
|
+
titleField,
|
|
156
|
+
defaultSort,
|
|
157
|
+
perPage,
|
|
158
|
+
fields,
|
|
159
|
+
$types: void 0
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
function storedFields(resource) {
|
|
163
|
+
return Object.entries(resource.fields).filter(([, def]) => def.kind !== "hasMany");
|
|
164
|
+
}
|
|
165
|
+
var columnName = (key) => snakeCase(key);
|
|
166
|
+
|
|
167
|
+
// src/resource/formats.ts
|
|
168
|
+
import {
|
|
169
|
+
getCountries,
|
|
170
|
+
getCountryCallingCode,
|
|
171
|
+
isValidPhoneNumber,
|
|
172
|
+
parsePhoneNumberFromString
|
|
173
|
+
} from "libphonenumber-js/min";
|
|
174
|
+
function flagEmoji(code) {
|
|
175
|
+
const upper = code.toUpperCase();
|
|
176
|
+
if (!/^[A-Z]{2}$/.test(upper)) return "";
|
|
177
|
+
return String.fromCodePoint(...[...upper].map((letter) => 127462 + letter.charCodeAt(0) - 65));
|
|
178
|
+
}
|
|
179
|
+
var namesCache = /* @__PURE__ */ new Map();
|
|
180
|
+
function displayNames(locale) {
|
|
181
|
+
if (!namesCache.has(locale)) {
|
|
182
|
+
try {
|
|
183
|
+
namesCache.set(locale, new Intl.DisplayNames([locale, "en"], { type: "region" }));
|
|
184
|
+
} catch {
|
|
185
|
+
namesCache.set(locale, null);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return namesCache.get(locale);
|
|
189
|
+
}
|
|
190
|
+
function countryName(code, locale = "en") {
|
|
191
|
+
const upper = code.toUpperCase();
|
|
192
|
+
try {
|
|
193
|
+
return displayNames(locale)?.of(upper) ?? upper;
|
|
194
|
+
} catch {
|
|
195
|
+
return upper;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
var listCache = /* @__PURE__ */ new Map();
|
|
199
|
+
function countries(locale = "en") {
|
|
200
|
+
let list = listCache.get(locale);
|
|
201
|
+
if (!list) {
|
|
202
|
+
list = getCountries().map((code) => ({ code, name: countryName(code, locale), dialCode: getCountryCallingCode(code), flag: flagEmoji(code) })).filter((country) => country.name !== country.code).sort((a, b) => a.name.localeCompare(b.name, locale));
|
|
203
|
+
listCache.set(locale, list);
|
|
204
|
+
}
|
|
205
|
+
return list;
|
|
206
|
+
}
|
|
207
|
+
function isCountryCode(value) {
|
|
208
|
+
return getCountries().includes(value.toUpperCase());
|
|
209
|
+
}
|
|
210
|
+
function isPhoneNumber(value) {
|
|
211
|
+
return value.startsWith("+") && isValidPhoneNumber(value);
|
|
212
|
+
}
|
|
213
|
+
function formatPhone(value) {
|
|
214
|
+
return parsePhoneNumberFromString(value)?.formatInternational() ?? value;
|
|
215
|
+
}
|
|
216
|
+
function splitPhone(value) {
|
|
217
|
+
const parsed = value ? parsePhoneNumberFromString(value) : void 0;
|
|
218
|
+
if (!parsed) return { country: void 0, national: value.replace(/^\+/, "") };
|
|
219
|
+
return { country: parsed.country, national: parsed.nationalNumber };
|
|
220
|
+
}
|
|
221
|
+
function toE164(country, national) {
|
|
222
|
+
const digits = national.replace(/[^\d+]/g, "");
|
|
223
|
+
if (!digits) return "";
|
|
224
|
+
if (digits.startsWith("+")) return parsePhoneNumberFromString(digits)?.number ?? digits;
|
|
225
|
+
const parsed = parsePhoneNumberFromString(digits, country.toUpperCase());
|
|
226
|
+
return parsed?.number ?? `+${getCountryCallingCode(country.toUpperCase())}${digits.replace(/^0+/, "")}`;
|
|
227
|
+
}
|
|
228
|
+
var DOMAIN = /^(?=.{1,253}$)(?:(?!-)[a-z0-9-]{1,63}(?<!-)\.)+[a-z][a-z0-9-]{0,62}[a-z0-9]$/;
|
|
229
|
+
function normalizeDomain(value) {
|
|
230
|
+
return value.trim().toLowerCase().replace(/^[a-z]+:\/\//, "").replace(/[/?#].*$/, "").replace(/\.$/, "");
|
|
231
|
+
}
|
|
232
|
+
var isDomain = (value) => DOMAIN.test(value);
|
|
233
|
+
var isColor = (value) => /^#[0-9a-f]{6}$/i.test(value);
|
|
234
|
+
var isSlug = (value) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value);
|
|
235
|
+
function slugify(value) {
|
|
236
|
+
return value.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// src/resource/validators.ts
|
|
240
|
+
import { z } from "zod";
|
|
241
|
+
var DEFAULT_STRING_MAX = 255;
|
|
242
|
+
var DEFAULT_TEXT_MAX = 65535;
|
|
243
|
+
var OBJECT_KEY = /^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$)).{1,1024}$/;
|
|
244
|
+
var typeError = (message) => (issue) => issue.input === void 0 || issue.input === null || issue.input === "" ? "Required" : message;
|
|
245
|
+
function stringSchema(max, min, required) {
|
|
246
|
+
let s = z.string({ error: typeError("Enter text") });
|
|
247
|
+
if (max) s = s.max(max, `At most ${max} characters`);
|
|
248
|
+
if (min !== void 0) s = s.min(min, min === 1 ? "Required" : `At least ${min} characters`);
|
|
249
|
+
else if (required) s = s.min(1, "Required");
|
|
250
|
+
return s;
|
|
251
|
+
}
|
|
252
|
+
function fieldSchema(def) {
|
|
253
|
+
switch (def.kind) {
|
|
254
|
+
case "string": {
|
|
255
|
+
let s = z.string({ error: typeError("Enter text") }).trim().max(def.maxLength ?? DEFAULT_STRING_MAX, `At most ${def.maxLength ?? DEFAULT_STRING_MAX} characters`);
|
|
256
|
+
if (def.minLength !== void 0) s = s.min(def.minLength, `At least ${def.minLength} characters`);
|
|
257
|
+
else if (def.required) s = s.min(1, "Required");
|
|
258
|
+
if (def.pattern) s = s.regex(new RegExp(def.pattern), "Invalid format");
|
|
259
|
+
switch (def.format) {
|
|
260
|
+
case "email":
|
|
261
|
+
return s.pipe(z.email({ error: "Enter a valid email address" }));
|
|
262
|
+
case "url":
|
|
263
|
+
return s.pipe(z.url({ protocol: /^https?$/, error: "Enter a valid URL (http or https)" }));
|
|
264
|
+
case "tel":
|
|
265
|
+
return s.refine((value) => value === "" || isPhoneNumber(value), "Enter a valid phone number, including the country");
|
|
266
|
+
case "domain":
|
|
267
|
+
return s.transform(normalizeDomain).refine((value) => value === "" || isDomain(value), "Enter a domain such as example.com");
|
|
268
|
+
case "country":
|
|
269
|
+
return s.transform((value) => value.toUpperCase()).refine((value) => value === "" || isCountryCode(value), "Choose a country");
|
|
270
|
+
case "color":
|
|
271
|
+
return s.transform((value) => value.toLowerCase()).refine((value) => value === "" || isColor(value), "Enter a colour such as #f2541d");
|
|
272
|
+
case "slug":
|
|
273
|
+
return s.refine((value) => value === "" || isSlug(value), "Use lowercase letters, numbers and hyphens, e.g. my-first-post");
|
|
274
|
+
default:
|
|
275
|
+
return s;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
case "text":
|
|
279
|
+
return stringSchema(def.maxLength ?? DEFAULT_TEXT_MAX, def.minLength, def.required);
|
|
280
|
+
case "int":
|
|
281
|
+
case "float": {
|
|
282
|
+
let s = z.number({ error: typeError("Enter a number") });
|
|
283
|
+
if (def.kind === "int") s = s.int("Enter a whole number");
|
|
284
|
+
if (def.min !== void 0) s = s.min(def.min, `Must be at least ${def.min}`);
|
|
285
|
+
if (def.max !== void 0) s = s.max(def.max, `Must be at most ${def.max}`);
|
|
286
|
+
return s;
|
|
287
|
+
}
|
|
288
|
+
case "boolean":
|
|
289
|
+
return z.boolean({ error: typeError("Choose yes or no") });
|
|
290
|
+
case "date":
|
|
291
|
+
return z.iso.date({ error: typeError("Enter a valid date") });
|
|
292
|
+
case "datetime":
|
|
293
|
+
return z.iso.datetime({ offset: true, error: typeError("Enter a valid date and time") });
|
|
294
|
+
case "enum":
|
|
295
|
+
return z.enum(def.options, { error: typeError("Choose one of the options") });
|
|
296
|
+
case "multiselect": {
|
|
297
|
+
let s = z.array(z.enum(def.options, { error: "Choose from the listed options" }), { error: typeError("Choose one or more options") }).refine((values) => new Set(values).size === values.length, "Each option can be picked once");
|
|
298
|
+
const min = def.minItems ?? (def.required ? 1 : 0);
|
|
299
|
+
if (min > 0) s = s.refine((values) => values.length >= min, min === 1 ? "Choose at least one" : `Choose at least ${min}`);
|
|
300
|
+
if (def.maxItems !== void 0) s = s.refine((values) => values.length <= def.maxItems, `Choose at most ${def.maxItems}`);
|
|
301
|
+
return s;
|
|
302
|
+
}
|
|
303
|
+
case "file":
|
|
304
|
+
return z.string({ error: typeError("Upload a file") }).regex(OBJECT_KEY, "Invalid file");
|
|
305
|
+
case "belongsTo":
|
|
306
|
+
return z.string({ error: typeError("Choose a record") }).min(1, "Required");
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
var fileKeyPrefix = (resource, fieldKey) => `${resource.table}/${fieldKey}/`;
|
|
310
|
+
function createValidators(resource) {
|
|
311
|
+
const create = {};
|
|
312
|
+
const update = {};
|
|
313
|
+
for (const [key, def] of storedFields(resource)) {
|
|
314
|
+
let base = fieldSchema(def);
|
|
315
|
+
if (def.kind === "file") {
|
|
316
|
+
const prefix = fileKeyPrefix(resource, key);
|
|
317
|
+
base = base.refine(
|
|
318
|
+
(value2) => typeof value2 !== "string" || !OBJECT_KEY.test(value2) || value2.startsWith(prefix),
|
|
319
|
+
"This file wasn't uploaded for this field"
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
const value = def.required ? base : base.nullable();
|
|
323
|
+
update[key] = value.optional();
|
|
324
|
+
const hasDefault = "default" in def && def.default !== void 0;
|
|
325
|
+
create[key] = def.required && !hasDefault ? value : value.optional();
|
|
326
|
+
}
|
|
327
|
+
const unknownKeys = { error: (issue) => issue.code === "unrecognized_keys" ? `Unknown field(s): ${issue.keys?.join(", ")}` : void 0 };
|
|
328
|
+
return {
|
|
329
|
+
create: z.strictObject(create, unknownKeys),
|
|
330
|
+
update: z.strictObject(update, unknownKeys)
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// src/resource/policy.ts
|
|
335
|
+
var ROLE = /^(\*|[a-z][a-z0-9_-]{0,31})$/;
|
|
336
|
+
var PolicyError = class extends Error {
|
|
337
|
+
constructor(message) {
|
|
338
|
+
super(message);
|
|
339
|
+
this.name = "PolicyError";
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
function definePolicy(config) {
|
|
343
|
+
if (!/^[A-Z][A-Za-z0-9]*$/.test(config.resource)) {
|
|
344
|
+
throw new PolicyError(`Policy resource "${config.resource}" must be a PascalCase resource name.`);
|
|
345
|
+
}
|
|
346
|
+
const clean = (action) => {
|
|
347
|
+
const roles = config[action] ?? [];
|
|
348
|
+
for (const role of roles) {
|
|
349
|
+
if (!ROLE.test(role)) throw new PolicyError(`Policy ${config.resource}.${action}: invalid role "${role}" (lowercase letters, digits, _ or -, or "*").`);
|
|
350
|
+
}
|
|
351
|
+
return [...new Set(roles)];
|
|
352
|
+
};
|
|
353
|
+
return { resource: config.resource, read: clean("read"), create: clean("create"), update: clean("update"), delete: clean("delete") };
|
|
354
|
+
}
|
|
355
|
+
function can(policy, role, action) {
|
|
356
|
+
if (!policy) return true;
|
|
357
|
+
const allowed = policy[action === "list" ? "read" : action];
|
|
358
|
+
if (allowed.includes("*")) return Boolean(role);
|
|
359
|
+
return Boolean(role) && allowed.includes(role);
|
|
360
|
+
}
|
|
361
|
+
function allowedActions(policy, role) {
|
|
362
|
+
return {
|
|
363
|
+
read: can(policy, role, "read"),
|
|
364
|
+
create: can(policy, role, "create"),
|
|
365
|
+
update: can(policy, role, "update"),
|
|
366
|
+
delete: can(policy, role, "delete")
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export {
|
|
371
|
+
STRING_FORMATS,
|
|
372
|
+
FILE_CATEGORIES,
|
|
373
|
+
mimeTypesFor,
|
|
374
|
+
DEFAULT_FILE_MAX_BYTES,
|
|
375
|
+
fileMaxBytes,
|
|
376
|
+
field,
|
|
377
|
+
words,
|
|
378
|
+
pascalCase,
|
|
379
|
+
camelCase,
|
|
380
|
+
snakeCase,
|
|
381
|
+
kebabCase,
|
|
382
|
+
humanize,
|
|
383
|
+
pluralize,
|
|
384
|
+
RESERVED_FIELD_NAMES,
|
|
385
|
+
ResourceDefinitionError,
|
|
386
|
+
defaultFieldLabel,
|
|
387
|
+
defineResource,
|
|
388
|
+
storedFields,
|
|
389
|
+
columnName,
|
|
390
|
+
flagEmoji,
|
|
391
|
+
countryName,
|
|
392
|
+
countries,
|
|
393
|
+
isCountryCode,
|
|
394
|
+
isPhoneNumber,
|
|
395
|
+
formatPhone,
|
|
396
|
+
splitPhone,
|
|
397
|
+
toE164,
|
|
398
|
+
normalizeDomain,
|
|
399
|
+
isDomain,
|
|
400
|
+
isColor,
|
|
401
|
+
isSlug,
|
|
402
|
+
slugify,
|
|
403
|
+
fieldSchema,
|
|
404
|
+
fileKeyPrefix,
|
|
405
|
+
createValidators,
|
|
406
|
+
PolicyError,
|
|
407
|
+
definePolicy,
|
|
408
|
+
can,
|
|
409
|
+
allowedActions
|
|
410
|
+
};
|
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 './
|
|
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, j as
|
|
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';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -358,7 +358,8 @@ declare function formatValue(def: StoredField | {
|
|
|
358
358
|
}, value: unknown, options?: FormatOptions): string;
|
|
359
359
|
|
|
360
360
|
/**
|
|
361
|
-
* Form state for a resource: strings for text-like inputs, booleans for toggles
|
|
361
|
+
* Form state for a resource: strings for text-like inputs, booleans for toggles, and a
|
|
362
|
+
* JSON array string for multiselects.
|
|
362
363
|
* These helpers convert between records, form state, and API input so every admin
|
|
363
364
|
* form handles empty values, numbers, and defaults the same way.
|
|
364
365
|
*/
|
|
@@ -371,47 +372,108 @@ declare function initialFormValues(resource: Resource, record?: Record<string, u
|
|
|
371
372
|
* empty required fields are left for validation to report as "Required".
|
|
372
373
|
*/
|
|
373
374
|
declare function formValuesToInput(resource: Resource, values: FormValues, mode: "create" | "edit"): Record<string, unknown>;
|
|
375
|
+
/** A multiselect's form state (a JSON array string) as the picked values. */
|
|
376
|
+
declare function parseMultiValue(raw: string | boolean | undefined): string[];
|
|
374
377
|
/** Field errors keyed by field name, from validation or API issues (first message wins). */
|
|
375
378
|
declare function issuesByField(issues: {
|
|
376
379
|
path: string | PropertyKey[];
|
|
377
380
|
message: string;
|
|
378
381
|
}[]): Record<string, string>;
|
|
379
382
|
|
|
383
|
+
interface Country {
|
|
384
|
+
/** ISO 3166-1 alpha-2, e.g. "UG". */
|
|
385
|
+
code: string;
|
|
386
|
+
name: string;
|
|
387
|
+
/** International dialling code without "+", e.g. "256". */
|
|
388
|
+
dialCode: string;
|
|
389
|
+
flag: string;
|
|
390
|
+
}
|
|
391
|
+
/** 🇺🇬 from "UG": two regional-indicator letters. */
|
|
392
|
+
declare function flagEmoji(code: string): string;
|
|
393
|
+
declare function countryName(code: string, locale?: string): string;
|
|
394
|
+
/** Every country with a dialling code, sorted by name in `locale`. */
|
|
395
|
+
declare function countries(locale?: string): Country[];
|
|
396
|
+
declare function isCountryCode(value: string): boolean;
|
|
397
|
+
/** A valid international number, e.g. "+256772123456". */
|
|
398
|
+
declare function isPhoneNumber(value: string): boolean;
|
|
399
|
+
/** "+256 772 123456" for display; the value itself when it can't be parsed. */
|
|
400
|
+
declare function formatPhone(value: string): string;
|
|
401
|
+
/** The country and national part of a stored number, to fill the admin input. */
|
|
402
|
+
declare function splitPhone(value: string): {
|
|
403
|
+
country: string | undefined;
|
|
404
|
+
national: string;
|
|
405
|
+
};
|
|
406
|
+
/** E.164 from a country and whatever the person typed, e.g. ("UG", "0772 123456") → "+256772123456". */
|
|
407
|
+
declare function toE164(country: string, national: string): string;
|
|
408
|
+
/** "https://Example.com/path" → "example.com": what someone pasted, as a bare hostname. */
|
|
409
|
+
declare function normalizeDomain(value: string): string;
|
|
410
|
+
declare const isDomain: (value: string) => boolean;
|
|
411
|
+
declare const isColor: (value: string) => boolean;
|
|
412
|
+
declare const isSlug: (value: string) => boolean;
|
|
413
|
+
/** "My First Post!" → "my-first-post". */
|
|
414
|
+
declare function slugify(value: string): string;
|
|
415
|
+
|
|
380
416
|
/**
|
|
381
|
-
*
|
|
417
|
+
* Sample data for seeds. Small on purpose: a handful of word lists and a seeded
|
|
418
|
+
* random number generator, so a million rows cost nothing to produce and the same
|
|
419
|
+
* seed always gives the same data.
|
|
382
420
|
*
|
|
383
|
-
*
|
|
384
|
-
*
|
|
385
|
-
*
|
|
421
|
+
* const fake = createFake();
|
|
422
|
+
* fake.fullName(); // "Amina Okello"
|
|
423
|
+
* fake.email(); // "amina.okello4@example.com"
|
|
386
424
|
*/
|
|
387
|
-
|
|
388
|
-
/**
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
425
|
+
interface Fake {
|
|
426
|
+
/** Whole number in [min, max]. */
|
|
427
|
+
int(min: number, max: number): number;
|
|
428
|
+
/** Number in [min, max], rounded to `decimals` places (default 2). */
|
|
429
|
+
float(min: number, max: number, decimals?: number): number;
|
|
430
|
+
/** True with the given probability (default 0.5). */
|
|
431
|
+
bool(chance?: number): boolean;
|
|
432
|
+
pick<T>(items: readonly T[]): T;
|
|
433
|
+
/** Between `min` and `max` distinct items, in the order they appear. */
|
|
434
|
+
some<T>(items: readonly T[], min?: number, max?: number): T[];
|
|
435
|
+
firstName(): string;
|
|
436
|
+
lastName(): string;
|
|
437
|
+
fullName(): string;
|
|
438
|
+
/** A unique address: the counter keeps it unique however many rows are made. */
|
|
439
|
+
email(name?: string): string;
|
|
440
|
+
company(): string;
|
|
441
|
+
jobTitle(): string;
|
|
442
|
+
city(): string;
|
|
443
|
+
/** ISO 3166-1 alpha-2, e.g. "UG". */
|
|
444
|
+
country(): string;
|
|
445
|
+
/** E.164, e.g. "+256772431980". */
|
|
446
|
+
phone(): string;
|
|
447
|
+
domain(): string;
|
|
448
|
+
url(): string;
|
|
449
|
+
slug(text?: string): string;
|
|
450
|
+
color(): string;
|
|
451
|
+
words(count?: number): string;
|
|
452
|
+
sentence(): string;
|
|
453
|
+
paragraph(sentences?: number): string;
|
|
454
|
+
/** "YYYY-MM-DD" within the last `daysBack` days (default 365). */
|
|
455
|
+
date(daysBack?: number): string;
|
|
456
|
+
/** ISO timestamp within the last `daysBack` days (default 365). */
|
|
457
|
+
datetime(daysBack?: number): string;
|
|
458
|
+
/** Milliseconds since the epoch, within the last `daysBack` days. */
|
|
459
|
+
timestamp(daysBack?: number): number;
|
|
460
|
+
uuid(): string;
|
|
461
|
+
/**
|
|
462
|
+
* A ULID: a 26-character id that sorts by the time it was made. Rows seeded with
|
|
463
|
+
* these go in at the end of the primary key's index instead of all over it, which
|
|
464
|
+
* measured 4× quicker than random ids at a few hundred thousand rows.
|
|
465
|
+
*/
|
|
466
|
+
id(): string;
|
|
467
|
+
/** How many values this generator has produced; unique per row. */
|
|
468
|
+
readonly count: number;
|
|
402
469
|
}
|
|
403
|
-
declare class PolicyError extends Error {
|
|
404
|
-
constructor(message: string);
|
|
405
|
-
}
|
|
406
|
-
declare function definePolicy(config: PolicyConfig): Policy;
|
|
407
470
|
/**
|
|
408
|
-
*
|
|
409
|
-
*
|
|
410
|
-
* `flare gen policy <Resource> --roles …` to restrict a resource.
|
|
471
|
+
* A generator of sample values. `seed` makes runs reproducible; leave it out for a
|
|
472
|
+
* different set every time.
|
|
411
473
|
*/
|
|
412
|
-
declare function
|
|
413
|
-
/**
|
|
414
|
-
declare
|
|
474
|
+
declare function createFake(seed?: number): Fake;
|
|
475
|
+
/** A shared generator, for seeds that don't need their own. */
|
|
476
|
+
declare const fake: Fake;
|
|
415
477
|
|
|
416
478
|
/**
|
|
417
479
|
* Seed files: `seeds/<name>.seed.ts`, run by `flare seed` against the local D1 database.
|
|
@@ -419,20 +481,32 @@ declare function allowedActions(policy: Policy | undefined | null, role: string
|
|
|
419
481
|
* import { defineSeed } from "@flaredev/core";
|
|
420
482
|
* import { contacts } from "@/db/schema";
|
|
421
483
|
*
|
|
422
|
-
* export default defineSeed(async ({ db }) => {
|
|
484
|
+
* export default defineSeed(async ({ db, insertMany, fake }) => {
|
|
423
485
|
* await db.insert(contacts).values([{ name: "Ada", email: "ada@example.com" }]);
|
|
486
|
+
* await insertMany(contacts, 10_000, () => ({ name: fake.fullName(), email: fake.email() }));
|
|
424
487
|
* });
|
|
425
488
|
*/
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Insert many rows at once. `rows` is either the rows themselves or a function called
|
|
492
|
+
* once per row, which keeps a million rows out of memory. Far quicker than a Drizzle
|
|
493
|
+
* insert of the same size: the values go into the SQL rather than into D1's
|
|
494
|
+
* 100-parameter budget.
|
|
495
|
+
*/
|
|
496
|
+
type InsertMany = <T extends object>(table: unknown, count: number | readonly T[], build?: (index: number) => T) => Promise<number>;
|
|
426
497
|
interface SeedContext {
|
|
427
498
|
/** Drizzle client over the app's schema, bound to the local D1 database. */
|
|
428
499
|
db: any;
|
|
429
500
|
/** All local bindings and variables (D1, R2, .dev.vars). */
|
|
430
501
|
env: Record<string, unknown>;
|
|
431
502
|
log: (message: string) => void;
|
|
503
|
+
insertMany: InsertMany;
|
|
504
|
+
/** Sample values: names, emails, sentences, dates. */
|
|
505
|
+
fake: Fake;
|
|
432
506
|
}
|
|
433
507
|
type Seed = (context: SeedContext) => void | Promise<void>;
|
|
434
508
|
declare function defineSeed(seed: Seed): Seed;
|
|
435
509
|
|
|
436
|
-
declare const FLARE_VERSION = "0.
|
|
510
|
+
declare const FLARE_VERSION = "0.3.0";
|
|
437
511
|
|
|
438
|
-
export { type BelongsToRelation, CreateInput, FLARE_VERSION, Field, type FormValues, type FormatOptions, type HasManyRelation, type MailMessage, type MailResult, type Mailer, type MailerOptions, PBKDF2_ITERATIONS, type
|
|
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 };
|