@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
package/dist/server.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as drizzle_orm_sqlite_core from 'drizzle-orm/sqlite-core';
|
|
2
2
|
import { BaseSQLiteDatabase, SQLiteTable } from 'drizzle-orm/sqlite-core';
|
|
3
|
-
import { S as StoredField, R as Resource, F as Field } from './
|
|
3
|
+
import { S as StoredField, R as Resource, F as Field, P as Policy } from './policy-BqRg9RVM.js';
|
|
4
4
|
|
|
5
5
|
interface ListQuery {
|
|
6
6
|
page: number;
|
|
@@ -153,4 +153,31 @@ declare function createResourceHandlers(options: ResourceHandlerOptions): {
|
|
|
153
153
|
};
|
|
154
154
|
};
|
|
155
155
|
|
|
156
|
-
|
|
156
|
+
/**
|
|
157
|
+
* An OpenAPI 3.1 document for an app's generated REST API, built at request time
|
|
158
|
+
* from the same descriptors and policies the API itself runs on, so it can't drift.
|
|
159
|
+
*
|
|
160
|
+
* Operations are filtered by role: a signed-in user sees what their role may call
|
|
161
|
+
* (admins everything), a visitor only the sign-in endpoints. `visibility: "public"`
|
|
162
|
+
* lists every operation to everyone.
|
|
163
|
+
*/
|
|
164
|
+
|
|
165
|
+
type Schema = Record<string, unknown>;
|
|
166
|
+
interface OpenApiOptions {
|
|
167
|
+
resources: readonly Resource[];
|
|
168
|
+
policies?: Record<string, Policy | undefined>;
|
|
169
|
+
/** The viewer's role; null for a signed-out visitor. */
|
|
170
|
+
role: string | null;
|
|
171
|
+
/** "role" (default): only operations the viewer's role may call. "public": all of them. */
|
|
172
|
+
visibility?: "role" | "public";
|
|
173
|
+
title?: string;
|
|
174
|
+
version?: string;
|
|
175
|
+
description?: string;
|
|
176
|
+
/** Absolute origin the "Try it" requests go to, e.g. https://app.example.com. */
|
|
177
|
+
serverUrl?: string;
|
|
178
|
+
}
|
|
179
|
+
/** JSON Schema for one field value (never null; nullability is added by the caller). */
|
|
180
|
+
declare function fieldJsonSchema(def: StoredField): Schema;
|
|
181
|
+
declare function openApiDocument(options: OpenApiOptions): Schema;
|
|
182
|
+
|
|
183
|
+
export { type AnyDatabase, type Authorize, type AuthorizeContext, type ChangeEvent, type Failure, type FieldIssue, type ListQuery, type ListResult, type OpenApiOptions, type QueryIssue, type ResourceAction, type ResourceHandlerOptions, type ResourceStore, type ResourceStoreOptions, type Result, createResourceHandlers, createResourceStore, fieldJsonSchema, isFilterable, isSearchable, isSortable, openApiDocument, parseListQuery };
|
package/dist/server.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
|
+
can,
|
|
2
3
|
createValidators,
|
|
3
4
|
storedFields
|
|
4
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-434JXSDV.js";
|
|
5
6
|
|
|
6
7
|
// ../../node_modules/.pnpm/drizzle-orm@0.45.2_@cloudfl_b372b29e6d5511672c7f9f9d2ac40572/node_modules/drizzle-orm/entity.js
|
|
7
8
|
var entityKind = /* @__PURE__ */ Symbol.for("drizzle:entityKind");
|
|
@@ -381,8 +382,8 @@ var PgColumnBuilder = class extends ColumnBuilder {
|
|
|
381
382
|
array(size) {
|
|
382
383
|
return new PgArrayBuilder(this.config.name, this, size);
|
|
383
384
|
}
|
|
384
|
-
references(
|
|
385
|
-
this.foreignKeyConfigs.push({ ref, actions });
|
|
385
|
+
references(ref2, actions = {}) {
|
|
386
|
+
this.foreignKeyConfigs.push({ ref: ref2, actions });
|
|
386
387
|
return this;
|
|
387
388
|
}
|
|
388
389
|
unique(name, config) {
|
|
@@ -401,11 +402,11 @@ var PgColumnBuilder = class extends ColumnBuilder {
|
|
|
401
402
|
}
|
|
402
403
|
/** @internal */
|
|
403
404
|
buildForeignKeys(column, table) {
|
|
404
|
-
return this.foreignKeyConfigs.map(({ ref, actions }) => {
|
|
405
|
+
return this.foreignKeyConfigs.map(({ ref: ref2, actions }) => {
|
|
405
406
|
return iife(
|
|
406
|
-
(
|
|
407
|
+
(ref22, actions2) => {
|
|
407
408
|
const builder = new ForeignKeyBuilder(() => {
|
|
408
|
-
const foreignColumn =
|
|
409
|
+
const foreignColumn = ref22();
|
|
409
410
|
return { columns: [column], foreignColumns: [foreignColumn] };
|
|
410
411
|
});
|
|
411
412
|
if (actions2.onUpdate) {
|
|
@@ -416,7 +417,7 @@ var PgColumnBuilder = class extends ColumnBuilder {
|
|
|
416
417
|
}
|
|
417
418
|
return builder.build(table);
|
|
418
419
|
},
|
|
419
|
-
|
|
420
|
+
ref2,
|
|
420
421
|
actions
|
|
421
422
|
);
|
|
422
423
|
});
|
|
@@ -1488,11 +1489,276 @@ function createResourceHandlers(options) {
|
|
|
1488
1489
|
store
|
|
1489
1490
|
};
|
|
1490
1491
|
}
|
|
1492
|
+
|
|
1493
|
+
// src/server/openapi.ts
|
|
1494
|
+
var E164 = "^\\+[1-9]\\d{6,14}$";
|
|
1495
|
+
function fieldJsonSchema(def) {
|
|
1496
|
+
const base = {};
|
|
1497
|
+
if (def.helpText) base.description = def.helpText;
|
|
1498
|
+
switch (def.kind) {
|
|
1499
|
+
case "string": {
|
|
1500
|
+
const schema = { ...base, type: "string", maxLength: def.maxLength ?? 255 };
|
|
1501
|
+
if (def.minLength !== void 0) schema.minLength = def.minLength;
|
|
1502
|
+
if (def.pattern) schema.pattern = def.pattern;
|
|
1503
|
+
switch (def.format) {
|
|
1504
|
+
case "email":
|
|
1505
|
+
return { ...schema, format: "email", examples: ["ada@example.com"] };
|
|
1506
|
+
case "url":
|
|
1507
|
+
return { ...schema, format: "uri", examples: ["https://example.com"] };
|
|
1508
|
+
case "tel":
|
|
1509
|
+
return { ...schema, pattern: E164, description: "International number in E.164 form.", examples: ["+256772123456"] };
|
|
1510
|
+
case "domain":
|
|
1511
|
+
return { ...schema, format: "hostname", description: "A hostname, stored lowercase without scheme or path.", examples: ["example.com"] };
|
|
1512
|
+
case "country":
|
|
1513
|
+
return { ...schema, pattern: "^[A-Z]{2}$", description: "ISO 3166-1 alpha-2 country code.", examples: ["UG"] };
|
|
1514
|
+
case "color":
|
|
1515
|
+
return { ...schema, pattern: "^#[0-9a-f]{6}$", description: "Hex colour.", examples: ["#f2541d"] };
|
|
1516
|
+
case "slug":
|
|
1517
|
+
return { ...schema, pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$", examples: ["my-first-post"] };
|
|
1518
|
+
default:
|
|
1519
|
+
return schema;
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
case "text":
|
|
1523
|
+
return { ...base, type: "string", maxLength: def.maxLength ?? 65535 };
|
|
1524
|
+
case "int":
|
|
1525
|
+
case "float": {
|
|
1526
|
+
const schema = { ...base, type: def.kind === "int" ? "integer" : "number" };
|
|
1527
|
+
if (def.min !== void 0) schema.minimum = def.min;
|
|
1528
|
+
if (def.max !== void 0) schema.maximum = def.max;
|
|
1529
|
+
return schema;
|
|
1530
|
+
}
|
|
1531
|
+
case "boolean":
|
|
1532
|
+
return { ...base, type: "boolean" };
|
|
1533
|
+
case "date":
|
|
1534
|
+
return { ...base, type: "string", format: "date" };
|
|
1535
|
+
case "datetime":
|
|
1536
|
+
return { ...base, type: "string", format: "date-time" };
|
|
1537
|
+
case "enum":
|
|
1538
|
+
return { ...base, type: "string", enum: [...def.options] };
|
|
1539
|
+
case "multiselect": {
|
|
1540
|
+
const schema = { ...base, type: "array", items: { type: "string", enum: [...def.options] }, uniqueItems: true };
|
|
1541
|
+
if (def.minItems !== void 0) schema.minItems = def.minItems;
|
|
1542
|
+
if (def.maxItems !== void 0) schema.maxItems = def.maxItems;
|
|
1543
|
+
return schema;
|
|
1544
|
+
}
|
|
1545
|
+
case "file":
|
|
1546
|
+
return { ...base, type: "string", description: base.description ?? "Storage key of an uploaded file." };
|
|
1547
|
+
case "belongsTo":
|
|
1548
|
+
return { ...base, type: "string", description: base.description ?? `id of a ${def.target}.` };
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
var nullable = (schema) => ({ ...schema, type: [schema.type, "null"] });
|
|
1552
|
+
function resourceSchemas(resource) {
|
|
1553
|
+
const record = { id: { type: "string", format: "uuid", readOnly: true } };
|
|
1554
|
+
const create = {};
|
|
1555
|
+
const required = [];
|
|
1556
|
+
const recordRequired = ["id", "createdAt", "updatedAt"];
|
|
1557
|
+
for (const [key, def] of storedFields(resource)) {
|
|
1558
|
+
const value = { ...fieldJsonSchema(def), title: def.label ?? key };
|
|
1559
|
+
record[key] = def.required ? value : nullable(value);
|
|
1560
|
+
create[key] = def.required ? value : nullable(value);
|
|
1561
|
+
if ("default" in def && def.default !== void 0) create[key] = { ...create[key], default: def.default };
|
|
1562
|
+
if (def.required) recordRequired.push(key);
|
|
1563
|
+
if (def.required && !("default" in def && def.default !== void 0)) required.push(key);
|
|
1564
|
+
}
|
|
1565
|
+
record.createdAt = { type: "string", format: "date-time", readOnly: true };
|
|
1566
|
+
record.updatedAt = { type: "string", format: "date-time", readOnly: true };
|
|
1567
|
+
return {
|
|
1568
|
+
[resource.name]: { type: "object", properties: record, required: recordRequired },
|
|
1569
|
+
[`${resource.name}Create`]: { type: "object", properties: create, required, additionalProperties: false },
|
|
1570
|
+
[`${resource.name}Update`]: { type: "object", properties: create, additionalProperties: false, description: "Only the fields to change." },
|
|
1571
|
+
[`${resource.name}Page`]: {
|
|
1572
|
+
type: "object",
|
|
1573
|
+
properties: {
|
|
1574
|
+
data: { type: "array", items: { $ref: `#/components/schemas/${resource.name}` } },
|
|
1575
|
+
meta: { $ref: "#/components/schemas/PageMeta" }
|
|
1576
|
+
},
|
|
1577
|
+
required: ["data", "meta"]
|
|
1578
|
+
}
|
|
1579
|
+
};
|
|
1580
|
+
}
|
|
1581
|
+
var json = (schema) => ({ "application/json": { schema } });
|
|
1582
|
+
var ref = (name) => ({ $ref: `#/components/schemas/${name}` });
|
|
1583
|
+
var problem2 = (description) => ({ description, content: json(ref("Problem")) });
|
|
1584
|
+
function rolesNote(policy, action) {
|
|
1585
|
+
if (!policy) return "Any signed-in user.";
|
|
1586
|
+
const roles = policy[action === "list" ? "read" : action];
|
|
1587
|
+
if (roles.includes("*")) return "Any signed-in user.";
|
|
1588
|
+
return roles.length ? `Roles: ${roles.join(", ")}.` : "No role may do this.";
|
|
1589
|
+
}
|
|
1590
|
+
function listParameters(resource) {
|
|
1591
|
+
const fields = storedFields(resource);
|
|
1592
|
+
const sortable = ["createdAt", "updatedAt", ...fields.filter(([, def]) => isSortable(def)).map(([key]) => key)];
|
|
1593
|
+
const parameters = [
|
|
1594
|
+
{ name: "page", in: "query", schema: { type: "integer", minimum: 1, default: 1 } },
|
|
1595
|
+
{ name: "perPage", in: "query", schema: { type: "integer", minimum: 1, maximum: 100, default: resource.perPage } },
|
|
1596
|
+
{
|
|
1597
|
+
name: "sort",
|
|
1598
|
+
in: "query",
|
|
1599
|
+
description: "Field to sort by; prefix with - for descending.",
|
|
1600
|
+
schema: { type: "string", enum: sortable.flatMap((key) => [key, `-${key}`]) }
|
|
1601
|
+
}
|
|
1602
|
+
];
|
|
1603
|
+
if (fields.some(([, def]) => isSearchable(def))) {
|
|
1604
|
+
parameters.push({ name: "q", in: "query", description: "Free-text search across the searchable fields.", schema: { type: "string", maxLength: 200 } });
|
|
1605
|
+
}
|
|
1606
|
+
for (const [key, def] of fields) {
|
|
1607
|
+
if (!isFilterable(def)) continue;
|
|
1608
|
+
parameters.push({ name: `filter[${key}]`, in: "query", description: `Only records whose ${key} equals this.`, schema: fieldJsonSchema(def) });
|
|
1609
|
+
}
|
|
1610
|
+
return parameters;
|
|
1611
|
+
}
|
|
1612
|
+
function openApiDocument(options) {
|
|
1613
|
+
const { resources, policies = {}, role, visibility = "role" } = options;
|
|
1614
|
+
const allowed = (resource, action) => visibility === "public" || role !== null && can(policies[resource.name], role, action);
|
|
1615
|
+
const paths = {};
|
|
1616
|
+
const schemas = {
|
|
1617
|
+
Problem: {
|
|
1618
|
+
type: "object",
|
|
1619
|
+
properties: {
|
|
1620
|
+
error: { type: "string", examples: ["Validation failed."] },
|
|
1621
|
+
issues: { type: "array", items: { type: "object", properties: { path: { type: "string" }, message: { type: "string" } } } },
|
|
1622
|
+
field: { type: "string", description: "The field a uniqueness conflict is about." }
|
|
1623
|
+
},
|
|
1624
|
+
required: ["error"]
|
|
1625
|
+
},
|
|
1626
|
+
PageMeta: {
|
|
1627
|
+
type: "object",
|
|
1628
|
+
properties: { page: { type: "integer" }, perPage: { type: "integer" }, total: { type: "integer" }, totalPages: { type: "integer" } },
|
|
1629
|
+
required: ["page", "perPage", "total", "totalPages"]
|
|
1630
|
+
}
|
|
1631
|
+
};
|
|
1632
|
+
const tags = [{ name: "Authentication", description: "Sign in here first: the session cookie authorizes every other request." }];
|
|
1633
|
+
const auth = {
|
|
1634
|
+
"/api/auth/sign-up/email": {
|
|
1635
|
+
post: {
|
|
1636
|
+
operationId: "signUp",
|
|
1637
|
+
tags: ["Authentication"],
|
|
1638
|
+
summary: "Create an account",
|
|
1639
|
+
security: [],
|
|
1640
|
+
requestBody: { required: true, content: json({ type: "object", properties: { name: { type: "string" }, email: { type: "string", format: "email" }, password: { type: "string", minLength: 8 } }, required: ["name", "email", "password"] }) },
|
|
1641
|
+
responses: { "200": { description: "Signed up and signed in; sets the session cookie." }, "422": problem2("The email is taken or the password too short.") }
|
|
1642
|
+
}
|
|
1643
|
+
},
|
|
1644
|
+
"/api/auth/sign-in/email": {
|
|
1645
|
+
post: {
|
|
1646
|
+
operationId: "signIn",
|
|
1647
|
+
tags: ["Authentication"],
|
|
1648
|
+
summary: "Sign in",
|
|
1649
|
+
security: [],
|
|
1650
|
+
requestBody: { required: true, content: json({ type: "object", properties: { email: { type: "string", format: "email" }, password: { type: "string" } }, required: ["email", "password"] }) },
|
|
1651
|
+
responses: { "200": { description: "Signed in; sets the session cookie." }, "401": problem2("Wrong email or password.") }
|
|
1652
|
+
}
|
|
1653
|
+
},
|
|
1654
|
+
"/api/auth/get-session": {
|
|
1655
|
+
get: { operationId: "getSession", tags: ["Authentication"], summary: "The current session", responses: { "200": { description: "The signed-in user and session, or null." } } }
|
|
1656
|
+
},
|
|
1657
|
+
"/api/auth/sign-out": {
|
|
1658
|
+
post: { operationId: "signOut", tags: ["Authentication"], summary: "Sign out", responses: { "200": { description: "Signed out; clears the session cookie." } } }
|
|
1659
|
+
}
|
|
1660
|
+
};
|
|
1661
|
+
Object.assign(paths, auth);
|
|
1662
|
+
for (const resource of resources) {
|
|
1663
|
+
const policy = policies[resource.name];
|
|
1664
|
+
const can2 = (action) => allowed(resource, action);
|
|
1665
|
+
if (!["list", "read", "create", "update", "delete"].some(can2)) continue;
|
|
1666
|
+
Object.assign(schemas, resourceSchemas(resource));
|
|
1667
|
+
const tag = resource.pluralLabel;
|
|
1668
|
+
const plural = tag.replace(/(?:^|\s+)(\w)/g, (_, letter) => letter.toUpperCase());
|
|
1669
|
+
tags.push({ name: tag, description: `The ${resource.pluralLabel.toLowerCase()} API, generated from resources/${resource.slug}.` });
|
|
1670
|
+
const denied = { "401": problem2("Sign in first."), "403": problem2("Your role can't do this.") };
|
|
1671
|
+
const collection = {};
|
|
1672
|
+
const item = {};
|
|
1673
|
+
const idParam = { name: "id", in: "path", required: true, schema: { type: "string" } };
|
|
1674
|
+
if (can2("list")) {
|
|
1675
|
+
collection.get = {
|
|
1676
|
+
operationId: `list${plural}`,
|
|
1677
|
+
tags: [tag],
|
|
1678
|
+
summary: `List ${resource.pluralLabel.toLowerCase()}`,
|
|
1679
|
+
description: rolesNote(policy, "list"),
|
|
1680
|
+
parameters: listParameters(resource),
|
|
1681
|
+
responses: { "200": { description: "One page of records.", content: json(ref(`${resource.name}Page`)) }, "400": problem2("A query parameter isn't accepted."), ...denied }
|
|
1682
|
+
};
|
|
1683
|
+
}
|
|
1684
|
+
if (can2("create")) {
|
|
1685
|
+
collection.post = {
|
|
1686
|
+
operationId: `create${resource.name}`,
|
|
1687
|
+
tags: [tag],
|
|
1688
|
+
summary: `Create a ${resource.label.toLowerCase()}`,
|
|
1689
|
+
description: rolesNote(policy, "create"),
|
|
1690
|
+
requestBody: { required: true, content: json(ref(`${resource.name}Create`)) },
|
|
1691
|
+
responses: {
|
|
1692
|
+
"201": { description: "Created. The Location header points at it.", content: json(ref(resource.name)) },
|
|
1693
|
+
"409": problem2("A unique field already has this value."),
|
|
1694
|
+
"422": problem2("Validation failed; `issues` lists each field."),
|
|
1695
|
+
...denied
|
|
1696
|
+
}
|
|
1697
|
+
};
|
|
1698
|
+
}
|
|
1699
|
+
if (can2("read")) {
|
|
1700
|
+
item.get = {
|
|
1701
|
+
operationId: `get${resource.name}`,
|
|
1702
|
+
tags: [tag],
|
|
1703
|
+
summary: `Get a ${resource.label.toLowerCase()}`,
|
|
1704
|
+
description: rolesNote(policy, "read"),
|
|
1705
|
+
parameters: [idParam],
|
|
1706
|
+
responses: { "200": { description: "The record.", content: json(ref(resource.name)) }, "404": problem2("No record with this id."), ...denied }
|
|
1707
|
+
};
|
|
1708
|
+
}
|
|
1709
|
+
if (can2("update")) {
|
|
1710
|
+
const body = (name) => ({ required: true, content: json(ref(name)) });
|
|
1711
|
+
const responses = {
|
|
1712
|
+
"200": { description: "The updated record.", content: json(ref(resource.name)) },
|
|
1713
|
+
"404": problem2("No record with this id."),
|
|
1714
|
+
"409": problem2("A unique field already has this value."),
|
|
1715
|
+
"422": problem2("Validation failed; `issues` lists each field."),
|
|
1716
|
+
...denied
|
|
1717
|
+
};
|
|
1718
|
+
item.patch = { operationId: `update${resource.name}`, tags: [tag], summary: `Update a ${resource.label.toLowerCase()}`, description: `Send only the fields to change. ${rolesNote(policy, "update")}`, parameters: [idParam], requestBody: body(`${resource.name}Update`), responses };
|
|
1719
|
+
item.put = { operationId: `replace${resource.name}`, tags: [tag], summary: `Replace a ${resource.label.toLowerCase()}`, description: `Send every field. ${rolesNote(policy, "update")}`, parameters: [idParam], requestBody: body(`${resource.name}Create`), responses };
|
|
1720
|
+
}
|
|
1721
|
+
if (can2("delete")) {
|
|
1722
|
+
item.delete = {
|
|
1723
|
+
operationId: `delete${resource.name}`,
|
|
1724
|
+
tags: [tag],
|
|
1725
|
+
summary: `Delete a ${resource.label.toLowerCase()}`,
|
|
1726
|
+
description: rolesNote(policy, "delete"),
|
|
1727
|
+
parameters: [idParam],
|
|
1728
|
+
responses: { "204": { description: "Deleted." }, "404": problem2("No record with this id."), "409": problem2("Other records still point at this one."), ...denied }
|
|
1729
|
+
};
|
|
1730
|
+
}
|
|
1731
|
+
if (Object.keys(collection).length) paths[`/api/${resource.slug}`] = collection;
|
|
1732
|
+
if (Object.keys(item).length) paths[`/api/${resource.slug}/{id}`] = item;
|
|
1733
|
+
}
|
|
1734
|
+
const visible = tags.length - 1;
|
|
1735
|
+
const intro = role === null && visibility === "role" ? "Sign in to see the endpoints available to you: use **Authentication \u2192 Sign in** below, or sign in to the app in this browser, then reload." : `${visible} resource API${visible === 1 ? "" : "s"} available to ${visibility === "public" ? "everyone" : `the **${role}** role`}.`;
|
|
1736
|
+
return {
|
|
1737
|
+
openapi: "3.1.0",
|
|
1738
|
+
info: {
|
|
1739
|
+
title: options.title ?? "API",
|
|
1740
|
+
version: options.version ?? "1.0.0",
|
|
1741
|
+
description: [options.description, intro, "Requests are authorized by the session cookie. Writes must be same-origin JSON (`Content-Type: application/json`)."].filter(Boolean).join("\n\n")
|
|
1742
|
+
},
|
|
1743
|
+
servers: options.serverUrl ? [{ url: options.serverUrl }] : void 0,
|
|
1744
|
+
security: [{ session: [] }],
|
|
1745
|
+
tags,
|
|
1746
|
+
paths,
|
|
1747
|
+
components: {
|
|
1748
|
+
schemas,
|
|
1749
|
+
securitySchemes: {
|
|
1750
|
+
session: { type: "apiKey", in: "cookie", name: "better-auth.session_token", description: "Set by signing in. Browsers send it automatically on same-origin requests." }
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
};
|
|
1754
|
+
}
|
|
1491
1755
|
export {
|
|
1492
1756
|
createResourceHandlers,
|
|
1493
1757
|
createResourceStore,
|
|
1758
|
+
fieldJsonSchema,
|
|
1494
1759
|
isFilterable,
|
|
1495
1760
|
isSearchable,
|
|
1496
1761
|
isSortable,
|
|
1762
|
+
openApiDocument,
|
|
1497
1763
|
parseListQuery
|
|
1498
1764
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flaredev/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Flare runtime: resource descriptors, field grammar, and Cloudflare-native helpers",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Muke Johnbaptist",
|
|
@@ -66,6 +66,7 @@
|
|
|
66
66
|
"README.md"
|
|
67
67
|
],
|
|
68
68
|
"dependencies": {
|
|
69
|
+
"libphonenumber-js": "1.13.13",
|
|
69
70
|
"zod": "^4.6.5"
|
|
70
71
|
},
|
|
71
72
|
"devDependencies": {
|
package/dist/chunk-DARDHZPX.js
DELETED
|
@@ -1,192 +0,0 @@
|
|
|
1
|
-
// src/resource/naming.ts
|
|
2
|
-
function words(value) {
|
|
3
|
-
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());
|
|
4
|
-
}
|
|
5
|
-
var pascalCase = (value) => words(value).map((w) => w[0].toUpperCase() + w.slice(1)).join("");
|
|
6
|
-
var camelCase = (value) => {
|
|
7
|
-
const pascal = pascalCase(value);
|
|
8
|
-
return pascal ? pascal[0].toLowerCase() + pascal.slice(1) : pascal;
|
|
9
|
-
};
|
|
10
|
-
var snakeCase = (value) => words(value).join("_");
|
|
11
|
-
var kebabCase = (value) => words(value).join("-");
|
|
12
|
-
var humanize = (value) => {
|
|
13
|
-
const text = words(value).join(" ");
|
|
14
|
-
return text ? text[0].toUpperCase() + text.slice(1) : text;
|
|
15
|
-
};
|
|
16
|
-
var UNCOUNTABLE = /* @__PURE__ */ new Set(["equipment", "information", "rice", "money", "species", "series", "fish", "sheep", "data", "news"]);
|
|
17
|
-
var IRREGULAR = { person: "people", man: "men", woman: "women", child: "children", mouse: "mice" };
|
|
18
|
-
function pluralize(value) {
|
|
19
|
-
const match = /([A-Za-z]+)$/.exec(value);
|
|
20
|
-
if (!match) return value;
|
|
21
|
-
const word = match[1];
|
|
22
|
-
const lower = word.toLowerCase();
|
|
23
|
-
const head = value.slice(0, value.length - word.length);
|
|
24
|
-
const keepCase = (plural) => word[0] === word[0].toUpperCase() ? plural[0].toUpperCase() + plural.slice(1) : plural;
|
|
25
|
-
if (UNCOUNTABLE.has(lower)) return value;
|
|
26
|
-
if (IRREGULAR[lower]) return head + keepCase(IRREGULAR[lower]);
|
|
27
|
-
if (/[^aeiou]y$/i.test(word)) return head + word.slice(0, -1) + "ies";
|
|
28
|
-
if (/(s|x|z|ch|sh)$/i.test(word)) return head + word + "es";
|
|
29
|
-
return head + word + "s";
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// src/resource/define.ts
|
|
33
|
-
var RESERVED_FIELD_NAMES = ["id", "createdAt", "updatedAt"];
|
|
34
|
-
var ResourceDefinitionError = class extends Error {
|
|
35
|
-
constructor(resource, message) {
|
|
36
|
-
super(`Resource "${resource}": ${message}`);
|
|
37
|
-
this.name = "ResourceDefinitionError";
|
|
38
|
-
}
|
|
39
|
-
};
|
|
40
|
-
var IDENTIFIER = /^[a-z][A-Za-z0-9]*$/;
|
|
41
|
-
function defaultFieldLabel(key, field) {
|
|
42
|
-
return humanize(field.kind === "belongsTo" ? key.replace(/Id$/, "") : key);
|
|
43
|
-
}
|
|
44
|
-
function defineResource(config) {
|
|
45
|
-
const name = config.name;
|
|
46
|
-
const fail = (message) => {
|
|
47
|
-
throw new ResourceDefinitionError(name, message);
|
|
48
|
-
};
|
|
49
|
-
if (!/^[A-Z][A-Za-z0-9]*$/.test(name) || pascalCase(name) !== name) fail('name must be PascalCase, e.g. "OrderItem".');
|
|
50
|
-
const entries = Object.entries(config.fields);
|
|
51
|
-
if (entries.length === 0) fail("define at least one field.");
|
|
52
|
-
const fields = {};
|
|
53
|
-
for (const [key, def] of entries) {
|
|
54
|
-
if (!IDENTIFIER.test(key)) fail(`field "${key}" must be camelCase (letters and digits, starting lowercase).`);
|
|
55
|
-
if (RESERVED_FIELD_NAMES.includes(key)) fail(`"${key}" is added automatically; don't declare it.`);
|
|
56
|
-
if (def.kind === "enum") {
|
|
57
|
-
if (def.options.length === 0) fail(`enum field "${key}" needs at least one option.`);
|
|
58
|
-
if (new Set(def.options).size !== def.options.length) fail(`enum field "${key}" has duplicate options.`);
|
|
59
|
-
if (def.default !== void 0 && !def.options.includes(def.default)) fail(`default of "${key}" isn't one of its options.`);
|
|
60
|
-
}
|
|
61
|
-
if (def.kind === "belongsTo" && !key.endsWith("Id")) fail(`belongsTo field "${key}" must end in "Id", e.g. "${camelCase(def.target)}Id".`);
|
|
62
|
-
if ((def.kind === "belongsTo" || def.kind === "hasMany") && pascalCase(def.target) !== def.target) {
|
|
63
|
-
fail(`relation "${key}" must target a PascalCase resource name.`);
|
|
64
|
-
}
|
|
65
|
-
if (def.kind === "belongsTo" && def.onDelete === "set null" && def.required) {
|
|
66
|
-
fail(`"${key}" uses onDelete "set null" but is required; mark it required: false.`);
|
|
67
|
-
}
|
|
68
|
-
if (def.kind === "file" && def.accept.length === 0) fail(`file field "${key}" must accept at least one category.`);
|
|
69
|
-
fields[key] = { ...def, label: def.label ?? defaultFieldLabel(key, def) };
|
|
70
|
-
}
|
|
71
|
-
const stored = entries.filter(([, def]) => def.kind !== "hasMany");
|
|
72
|
-
const titleField = config.titleField ?? stored.find(([, def]) => def.kind === "string")?.[0] ?? stored[0]?.[0] ?? "id";
|
|
73
|
-
if (config.titleField && !stored.some(([key]) => key === config.titleField)) {
|
|
74
|
-
fail(`titleField "${config.titleField}" isn't a stored field.`);
|
|
75
|
-
}
|
|
76
|
-
const defaultSort = config.defaultSort ?? { field: "createdAt", direction: "desc" };
|
|
77
|
-
if (!["createdAt", "updatedAt"].includes(defaultSort.field) && !stored.some(([key]) => key === defaultSort.field)) {
|
|
78
|
-
fail(`defaultSort field "${defaultSort.field}" isn't a stored field.`);
|
|
79
|
-
}
|
|
80
|
-
const perPage = config.perPage ?? 25;
|
|
81
|
-
if (!Number.isInteger(perPage) || perPage < 1 || perPage > 100) fail("perPage must be an integer from 1 to 100.");
|
|
82
|
-
const pluralName = pluralize(name);
|
|
83
|
-
return {
|
|
84
|
-
name,
|
|
85
|
-
table: config.table ?? snakeCase(pluralName),
|
|
86
|
-
slug: config.slug ?? kebabCase(pluralName),
|
|
87
|
-
label: config.label ?? humanize(name),
|
|
88
|
-
pluralLabel: config.pluralLabel ?? humanize(pluralName),
|
|
89
|
-
icon: config.icon,
|
|
90
|
-
titleField,
|
|
91
|
-
defaultSort,
|
|
92
|
-
perPage,
|
|
93
|
-
fields,
|
|
94
|
-
$types: void 0
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
function storedFields(resource) {
|
|
98
|
-
return Object.entries(resource.fields).filter(([, def]) => def.kind !== "hasMany");
|
|
99
|
-
}
|
|
100
|
-
var columnName = (key) => snakeCase(key);
|
|
101
|
-
|
|
102
|
-
// src/resource/validators.ts
|
|
103
|
-
import { z } from "zod";
|
|
104
|
-
var DEFAULT_STRING_MAX = 255;
|
|
105
|
-
var DEFAULT_TEXT_MAX = 65535;
|
|
106
|
-
var OBJECT_KEY = /^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$)).{1,1024}$/;
|
|
107
|
-
var typeError = (message) => (issue) => issue.input === void 0 || issue.input === null || issue.input === "" ? "Required" : message;
|
|
108
|
-
function stringSchema(max, min, required) {
|
|
109
|
-
let s = z.string({ error: typeError("Enter text") });
|
|
110
|
-
if (max) s = s.max(max, `At most ${max} characters`);
|
|
111
|
-
if (min !== void 0) s = s.min(min, min === 1 ? "Required" : `At least ${min} characters`);
|
|
112
|
-
else if (required) s = s.min(1, "Required");
|
|
113
|
-
return s;
|
|
114
|
-
}
|
|
115
|
-
function fieldSchema(def) {
|
|
116
|
-
switch (def.kind) {
|
|
117
|
-
case "string": {
|
|
118
|
-
let s = z.string({ error: typeError("Enter text") }).trim().max(def.maxLength ?? DEFAULT_STRING_MAX, `At most ${def.maxLength ?? DEFAULT_STRING_MAX} characters`);
|
|
119
|
-
if (def.minLength !== void 0) s = s.min(def.minLength, `At least ${def.minLength} characters`);
|
|
120
|
-
else if (def.required) s = s.min(1, "Required");
|
|
121
|
-
if (def.pattern) s = s.regex(new RegExp(def.pattern), "Invalid format");
|
|
122
|
-
if (def.format === "email") return s.pipe(z.email({ error: "Enter a valid email address" }));
|
|
123
|
-
if (def.format === "url") return s.pipe(z.url({ protocol: /^https?$/, error: "Enter a valid URL (http or https)" }));
|
|
124
|
-
return s;
|
|
125
|
-
}
|
|
126
|
-
case "text":
|
|
127
|
-
return stringSchema(def.maxLength ?? DEFAULT_TEXT_MAX, def.minLength, def.required);
|
|
128
|
-
case "int":
|
|
129
|
-
case "float": {
|
|
130
|
-
let s = z.number({ error: typeError("Enter a number") });
|
|
131
|
-
if (def.kind === "int") s = s.int("Enter a whole number");
|
|
132
|
-
if (def.min !== void 0) s = s.min(def.min, `Must be at least ${def.min}`);
|
|
133
|
-
if (def.max !== void 0) s = s.max(def.max, `Must be at most ${def.max}`);
|
|
134
|
-
return s;
|
|
135
|
-
}
|
|
136
|
-
case "boolean":
|
|
137
|
-
return z.boolean({ error: typeError("Choose yes or no") });
|
|
138
|
-
case "date":
|
|
139
|
-
return z.iso.date({ error: typeError("Enter a valid date") });
|
|
140
|
-
case "datetime":
|
|
141
|
-
return z.iso.datetime({ offset: true, error: typeError("Enter a valid date and time") });
|
|
142
|
-
case "enum":
|
|
143
|
-
return z.enum(def.options, { error: typeError("Choose one of the options") });
|
|
144
|
-
case "file":
|
|
145
|
-
return z.string({ error: typeError("Upload a file") }).regex(OBJECT_KEY, "Invalid file");
|
|
146
|
-
case "belongsTo":
|
|
147
|
-
return z.string({ error: typeError("Choose a record") }).min(1, "Required");
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
var fileKeyPrefix = (resource, fieldKey) => `${resource.table}/${fieldKey}/`;
|
|
151
|
-
function createValidators(resource) {
|
|
152
|
-
const create = {};
|
|
153
|
-
const update = {};
|
|
154
|
-
for (const [key, def] of storedFields(resource)) {
|
|
155
|
-
let base = fieldSchema(def);
|
|
156
|
-
if (def.kind === "file") {
|
|
157
|
-
const prefix = fileKeyPrefix(resource, key);
|
|
158
|
-
base = base.refine(
|
|
159
|
-
(value2) => typeof value2 !== "string" || !OBJECT_KEY.test(value2) || value2.startsWith(prefix),
|
|
160
|
-
"This file wasn't uploaded for this field"
|
|
161
|
-
);
|
|
162
|
-
}
|
|
163
|
-
const value = def.required ? base : base.nullable();
|
|
164
|
-
update[key] = value.optional();
|
|
165
|
-
const hasDefault = "default" in def && def.default !== void 0;
|
|
166
|
-
create[key] = def.required && !hasDefault ? value : value.optional();
|
|
167
|
-
}
|
|
168
|
-
const unknownKeys = { error: (issue) => issue.code === "unrecognized_keys" ? `Unknown field(s): ${issue.keys?.join(", ")}` : void 0 };
|
|
169
|
-
return {
|
|
170
|
-
create: z.strictObject(create, unknownKeys),
|
|
171
|
-
update: z.strictObject(update, unknownKeys)
|
|
172
|
-
};
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
export {
|
|
176
|
-
words,
|
|
177
|
-
pascalCase,
|
|
178
|
-
camelCase,
|
|
179
|
-
snakeCase,
|
|
180
|
-
kebabCase,
|
|
181
|
-
humanize,
|
|
182
|
-
pluralize,
|
|
183
|
-
RESERVED_FIELD_NAMES,
|
|
184
|
-
ResourceDefinitionError,
|
|
185
|
-
defaultFieldLabel,
|
|
186
|
-
defineResource,
|
|
187
|
-
storedFields,
|
|
188
|
-
columnName,
|
|
189
|
-
fieldSchema,
|
|
190
|
-
fileKeyPrefix,
|
|
191
|
-
createValidators
|
|
192
|
-
};
|