@metaobjectsdev/runtime-ts 1.0.13 → 1.1.1-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/decimal-wire.d.ts +54 -0
- package/dist/decimal-wire.d.ts.map +1 -0
- package/dist/decimal-wire.js +158 -0
- package/dist/decimal-wire.js.map +1 -0
- package/dist/drizzle-fastify/filter-allowlist.d.ts +4 -2
- package/dist/drizzle-fastify/filter-allowlist.d.ts.map +1 -1
- package/dist/drizzle-fastify/filter-allowlist.js +2 -1
- package/dist/drizzle-fastify/filter-allowlist.js.map +1 -1
- package/dist/drizzle-fastify/filter-parser.d.ts.map +1 -1
- package/dist/drizzle-fastify/filter-parser.js +23 -9
- package/dist/drizzle-fastify/filter-parser.js.map +1 -1
- package/dist/drizzle-fastify/filter-value-format.d.ts.map +1 -1
- package/dist/drizzle-fastify/filter-value-format.js +4 -1
- package/dist/drizzle-fastify/filter-value-format.js.map +1 -1
- package/dist/drizzle-fastify/index.d.ts +8 -0
- package/dist/drizzle-fastify/index.d.ts.map +1 -1
- package/dist/drizzle-fastify/index.js +4 -2
- package/dist/drizzle-fastify/index.js.map +1 -1
- package/dist/drizzle-fastify/mount-m2m.d.ts +2 -0
- package/dist/drizzle-fastify/mount-m2m.d.ts.map +1 -1
- package/dist/drizzle-fastify/mount-m2m.js +2 -1
- package/dist/drizzle-fastify/mount-m2m.js.map +1 -1
- package/dist/drizzle-fastify/mount-read-only.d.ts +25 -1
- package/dist/drizzle-fastify/mount-read-only.d.ts.map +1 -1
- package/dist/drizzle-fastify/mount-read-only.js +43 -28
- package/dist/drizzle-fastify/mount-read-only.js.map +1 -1
- package/dist/extract-object.js +5 -7
- package/dist/extract-object.js.map +1 -1
- package/dist/hono/index.d.ts +8 -0
- package/dist/hono/index.d.ts.map +1 -1
- package/dist/hono/index.js +4 -2
- package/dist/hono/index.js.map +1 -1
- package/dist/hono/mount-read-only.d.ts +25 -1
- package/dist/hono/mount-read-only.d.ts.map +1 -1
- package/dist/hono/mount-read-only.js +51 -36
- package/dist/hono/mount-read-only.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/n2m-resolver.d.ts.map +1 -1
- package/dist/n2m-resolver.js +4 -3
- package/dist/n2m-resolver.js.map +1 -1
- package/dist/net-format.d.ts +8 -0
- package/dist/net-format.d.ts.map +1 -0
- package/dist/net-format.js +34 -0
- package/dist/net-format.js.map +1 -0
- package/dist/object-manager.d.ts +20 -7
- package/dist/object-manager.d.ts.map +1 -1
- package/dist/object-manager.js +68 -13
- package/dist/object-manager.js.map +1 -1
- package/dist/query-builder.d.ts.map +1 -1
- package/dist/query-builder.js +11 -2
- package/dist/query-builder.js.map +1 -1
- package/dist/relation-resolver.d.ts.map +1 -1
- package/dist/relation-resolver.js +11 -11
- package/dist/relation-resolver.js.map +1 -1
- package/dist/type-coercer.d.ts +1 -1
- package/dist/type-coercer.d.ts.map +1 -1
- package/dist/type-coercer.js +114 -5
- package/dist/type-coercer.js.map +1 -1
- package/dist/validator-runner.d.ts.map +1 -1
- package/dist/validator-runner.js +192 -35
- package/dist/validator-runner.js.map +1 -1
- package/package.json +3 -3
- package/src/decimal-wire.ts +154 -0
- package/src/drizzle-fastify/filter-allowlist.ts +5 -1
- package/src/drizzle-fastify/filter-parser.ts +22 -9
- package/src/drizzle-fastify/filter-value-format.ts +4 -0
- package/src/drizzle-fastify/index.ts +11 -2
- package/src/drizzle-fastify/mount-m2m.ts +4 -1
- package/src/drizzle-fastify/mount-read-only.ts +68 -31
- package/src/extract-object.ts +5 -7
- package/src/hono/index.ts +11 -2
- package/src/hono/mount-read-only.ts +76 -37
- package/src/index.ts +2 -1
- package/src/n2m-resolver.ts +4 -2
- package/src/net-format.ts +37 -0
- package/src/object-manager.ts +80 -18
- package/src/query-builder.ts +14 -1
- package/src/relation-resolver.ts +15 -14
- package/src/type-coercer.ts +116 -5
- package/src/validator-runner.ts +195 -32
|
@@ -15,7 +15,8 @@ import {
|
|
|
15
15
|
ANY_TEMPORAL_EXPECTED, FORMAT_EXPECTED, PLUS_OFFSET_HINT,
|
|
16
16
|
looksLikeUnencodedPlusOffset, matchesAnyTemporal, matchesFormat, utcIsoIfZoned, utcIsoInstant,
|
|
17
17
|
} from "./filter-value-format.js";
|
|
18
|
-
import { FIELD_SUBTYPE_TIMESTAMP } from "@metaobjectsdev/metadata";
|
|
18
|
+
import { FIELD_SUBTYPE_DECIMAL, FIELD_SUBTYPE_TIMESTAMP } from "@metaobjectsdev/metadata";
|
|
19
|
+
import { canonicalDecimal } from "../decimal-wire.js";
|
|
19
20
|
|
|
20
21
|
// biome-ignore lint/suspicious/noExplicitAny: dynamic dispatch over user's Drizzle table
|
|
21
22
|
type AnyTable = any;
|
|
@@ -215,14 +216,14 @@ function compileOp(
|
|
|
215
216
|
throw new FilterParseError("filter.unsupported_op", `Op "${op}" not supported for field "${field}".`, { field, op, allowed: rule.ops });
|
|
216
217
|
}
|
|
217
218
|
switch (op as FilterOp) {
|
|
218
|
-
case "eq": return eq(col as any, coerce(value, rule, col, field, op));
|
|
219
|
-
case "ne": return ne(col as any, coerce(value, rule, col, field, op));
|
|
220
|
-
case "gt": return gt(col as any, coerce(value, rule, col, field, op));
|
|
221
|
-
case "gte": return gte(col as any, coerce(value, rule, col, field, op));
|
|
222
|
-
case "lt": return lt(col as any, coerce(value, rule, col, field, op));
|
|
223
|
-
case "lte": return lte(col as any, coerce(value, rule, col, field, op));
|
|
219
|
+
case "eq": return eq(col as any, coerce(value, rule, col, field, op, dialect));
|
|
220
|
+
case "ne": return ne(col as any, coerce(value, rule, col, field, op, dialect));
|
|
221
|
+
case "gt": return gt(col as any, coerce(value, rule, col, field, op, dialect));
|
|
222
|
+
case "gte": return gte(col as any, coerce(value, rule, col, field, op, dialect));
|
|
223
|
+
case "lt": return lt(col as any, coerce(value, rule, col, field, op, dialect));
|
|
224
|
+
case "lte": return lte(col as any, coerce(value, rule, col, field, op, dialect));
|
|
224
225
|
case "in": {
|
|
225
|
-
const list = String(value).split(",").map((v) => coerce(v.trim(), rule, col, field, op));
|
|
226
|
+
const list = String(value).split(",").map((v) => coerce(v.trim(), rule, col, field, op, dialect));
|
|
226
227
|
if (list.length > maxInList) {
|
|
227
228
|
throw new FilterParseError("filter.in_too_large", `In-list size ${list.length} exceeds limit ${maxInList}.`, { field, limit: maxInList });
|
|
228
229
|
}
|
|
@@ -327,10 +328,22 @@ function assertWellFormed(s: string, rule: FilterFieldRule, col: unknown, field:
|
|
|
327
328
|
}
|
|
328
329
|
|
|
329
330
|
/** Check a comparison value against the field's rule, then coerce it for binding. */
|
|
330
|
-
function coerce(value: unknown, rule: FilterFieldRule, col: unknown, field: string, op: string): unknown {
|
|
331
|
+
function coerce(value: unknown, rule: FilterFieldRule, col: unknown, field: string, op: string, dialect: SqlDialect): unknown {
|
|
331
332
|
if (value === null || value === undefined) return null;
|
|
332
333
|
const s = typeof value === "string" ? value : String(value);
|
|
333
334
|
assertWellFormed(s, rule, col, field, op);
|
|
335
|
+
// A decimal operand is exact text, never a number (ADR-0019 Amendment 1, W2): parsed with
|
|
336
|
+
// the JSON-number grammar and bound as its canonical string. MySQL compares a DECIMAL column
|
|
337
|
+
// with a string operand as DOUBLES, so there the operand is cast to a wide DECIMAL first.
|
|
338
|
+
// SQLite and D1 hold a decimal as a double (W4) and order every number before any text, so a
|
|
339
|
+
// text operand against a computed column (a view's sum or average has no NUMERIC affinity)
|
|
340
|
+
// would compare false in the wrong direction: the operand is bound as the number it is, exact
|
|
341
|
+
// to the 15 digits W4 promises.
|
|
342
|
+
if (rule.format === FIELD_SUBTYPE_DECIMAL) {
|
|
343
|
+
const canonical = canonicalDecimal(s) as string;
|
|
344
|
+
if (dialect === "mysql") return sql`CAST(${canonical} AS DECIMAL(65,30))`;
|
|
345
|
+
return dialect === "sqlite" ? Number(canonical) : canonical;
|
|
346
|
+
}
|
|
334
347
|
// A timestamp bound is compared in the same UTC spelling the generated schemas store:
|
|
335
348
|
// on SQLite/D1 the column is TEXT and compares as text. An instant field (`instant`, i.e.
|
|
336
349
|
// not @localTime) reads a zoneless or date-only bound as UTC, as its write schema does —
|
|
@@ -13,11 +13,13 @@
|
|
|
13
13
|
|
|
14
14
|
import {
|
|
15
15
|
FIELD_SUBTYPE_DATE,
|
|
16
|
+
FIELD_SUBTYPE_DECIMAL,
|
|
16
17
|
FIELD_SUBTYPE_TIME,
|
|
17
18
|
FIELD_SUBTYPE_TIMESTAMP,
|
|
18
19
|
FIELD_SUBTYPE_UUID,
|
|
19
20
|
} from "@metaobjectsdev/metadata";
|
|
20
21
|
import type { FilterValueFormat } from "./filter-allowlist.js";
|
|
22
|
+
import { canonicalDecimal } from "../decimal-wire.js";
|
|
21
23
|
|
|
22
24
|
const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
23
25
|
const TIME_RE = /^(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d{1,9})?)?$/;
|
|
@@ -45,6 +47,7 @@ const CHECKS: Readonly<Record<FilterValueFormat, (s: string) => boolean>> = {
|
|
|
45
47
|
[FIELD_SUBTYPE_TIME]: (s) => TIME_RE.test(s),
|
|
46
48
|
[FIELD_SUBTYPE_TIMESTAMP]: isTimestamp,
|
|
47
49
|
[FIELD_SUBTYPE_UUID]: (s) => UUID_RE.test(s),
|
|
50
|
+
[FIELD_SUBTYPE_DECIMAL]: (s) => canonicalDecimal(s) !== undefined,
|
|
48
51
|
};
|
|
49
52
|
|
|
50
53
|
/** The `expected` member of the `invalid_filter_value` envelope, per format. */
|
|
@@ -53,6 +56,7 @@ export const FORMAT_EXPECTED: Readonly<Record<FilterValueFormat, string>> = {
|
|
|
53
56
|
[FIELD_SUBTYPE_TIME]: "time (HH:MM[:SS[.fff]])",
|
|
54
57
|
[FIELD_SUBTYPE_TIMESTAMP]: "timestamp (YYYY-MM-DD[THH:MM[:SS[.fff]]][Z|±HH:MM])",
|
|
55
58
|
[FIELD_SUBTYPE_UUID]: "uuid (8-4-4-4-12 hex)",
|
|
59
|
+
[FIELD_SUBTYPE_DECIMAL]: "decimal number (digits, optional fraction, optional exponent)",
|
|
56
60
|
};
|
|
57
61
|
|
|
58
62
|
/** `expected` for a temporal rule that does not say WHICH temporal format it is. */
|
|
@@ -29,11 +29,13 @@ export type { FilterAllowlist, SortAllowlist } from "./filter-allowlist.js";
|
|
|
29
29
|
import { parseFilterParams, parsePageBound, FilterParseError } from "./filter-parser.js";
|
|
30
30
|
import { isTruthyFlag, contractErrorCode, coerceIdForColumn, firstRow, extractRowCount } from "./util.js";
|
|
31
31
|
import { timestampWire } from "../timestamp-wire.js";
|
|
32
|
+
import { withDecimals } from "../decimal-wire.js";
|
|
32
33
|
import { supportsReturning, type SqlDialect } from "../sql-dialect.js";
|
|
33
34
|
import { withContractErrorHandler } from "./route-error-handler.js";
|
|
34
35
|
import { validationErrorBody } from "../route-errors.js";
|
|
35
36
|
export { isTruthyFlag, contractErrorCode, parseId, coerceIdForColumn } from "./util.js";
|
|
36
37
|
export { timestampWire, canonicalTimestamp } from "../timestamp-wire.js";
|
|
38
|
+
export { decimalWire, withDecimals, canonicalDecimal, parseDecimalInput, parseDecimalOperand } from "../decimal-wire.js";
|
|
37
39
|
|
|
38
40
|
// ---------------------------------------------------------------------------
|
|
39
41
|
// Loose types — we don't bind to a specific Drizzle backend so the helper
|
|
@@ -84,6 +86,13 @@ export interface CrudRoutesOptions {
|
|
|
84
86
|
updateMethod?: "patch" | "put";
|
|
85
87
|
filterAllowlist?: FilterAllowlist;
|
|
86
88
|
sortAllowlist?: SortAllowlist;
|
|
89
|
+
/**
|
|
90
|
+
* The names of the entity's `field.decimal` fields. A decimal is a canonical string on the
|
|
91
|
+
* wire on every dialect (ADR-0019 Amendment 1): Postgres and MySQL return the column's scale
|
|
92
|
+
* (`"100.5000"`) and SQLite hands back a number, so each row the routes send is rewritten
|
|
93
|
+
* under these keys. The generated route passes them; absent, rows are sent as read.
|
|
94
|
+
*/
|
|
95
|
+
decimalColumns?: readonly string[];
|
|
87
96
|
/** Dialect — required if filterAllowlist or sortAllowlist is set (for dialect-specific `like` lowering: SQLite lowers to GLOB to stay case-sensitive; ADR-0049). */
|
|
88
97
|
dialect?: SqlDialect;
|
|
89
98
|
/**
|
|
@@ -122,7 +131,7 @@ function readSource(opts: VerbOptions): AnyTable {
|
|
|
122
131
|
* non-timestamp of the same name.
|
|
123
132
|
*/
|
|
124
133
|
function readWire(opts: VerbOptions): (row: unknown) => unknown {
|
|
125
|
-
return timestampWire(readSource(opts));
|
|
134
|
+
return withDecimals(timestampWire(readSource(opts)), opts.decimalColumns);
|
|
126
135
|
}
|
|
127
136
|
|
|
128
137
|
/**
|
|
@@ -131,7 +140,7 @@ function readWire(opts: VerbOptions): (row: unknown) => unknown {
|
|
|
131
140
|
* against the union of both sources.
|
|
132
141
|
*/
|
|
133
142
|
function echoWire(opts: VerbOptions): (row: unknown) => unknown {
|
|
134
|
-
return timestampWire(opts.table, opts.readView);
|
|
143
|
+
return withDecimals(timestampWire(opts.table, opts.readView), opts.decimalColumns);
|
|
135
144
|
}
|
|
136
145
|
|
|
137
146
|
/** #214 — after a write to the base table, re-read the row THROUGH the replica
|
|
@@ -34,6 +34,7 @@ import type { FastifyInstance, RouteShorthandOptions } from "fastify";
|
|
|
34
34
|
import { and, eq, or, inArray } from "drizzle-orm";
|
|
35
35
|
import { coerceIdForColumn } from "./util.js";
|
|
36
36
|
import { timestampWire } from "../timestamp-wire.js";
|
|
37
|
+
import { withDecimals } from "../decimal-wire.js";
|
|
37
38
|
import { withContractErrorHandler } from "./route-error-handler.js";
|
|
38
39
|
|
|
39
40
|
// Loose Drizzle types — the helper works across libsql / better-sqlite3 / pg.
|
|
@@ -60,6 +61,8 @@ export interface M2mRouteOptions {
|
|
|
60
61
|
targetColumn: string;
|
|
61
62
|
/** Target entity PK column (physical column name, e.g. "id"). Defaults to "id". */
|
|
62
63
|
targetPkColumn?: string;
|
|
64
|
+
/** The names of the TARGET entity's `field.decimal` fields, sent as canonical strings (ADR-0019 Amendment 1). */
|
|
65
|
+
decimalColumns?: readonly string[];
|
|
63
66
|
/** Undirected self-join: union both junction FK columns on read. */
|
|
64
67
|
symmetric: boolean;
|
|
65
68
|
/**
|
|
@@ -87,7 +90,7 @@ export function mountM2mRoute(opts: M2mRouteOptions): void {
|
|
|
87
90
|
const route = `${opts.path}/:id/${opts.relationName}`;
|
|
88
91
|
// Route-scoped contract error handler — see route-error-handler.ts.
|
|
89
92
|
const ro = withContractErrorHandler(opts.routeOptions);
|
|
90
|
-
const toWire = timestampWire(opts.targetTable);
|
|
93
|
+
const toWire = withDecimals(timestampWire(opts.targetTable), opts.decimalColumns);
|
|
91
94
|
|
|
92
95
|
opts.fastify.get(route, ro, async (req, reply) => {
|
|
93
96
|
const { id } = req.params as { id: string };
|
|
@@ -6,6 +6,7 @@ import { parseFilterParams, parsePageBound, RAW_VIEW_MAX_LIMIT, FilterParseError
|
|
|
6
6
|
import type { FilterAllowlist, SortAllowlist } from "./filter-allowlist.js";
|
|
7
7
|
import { isTruthyFlag, contractErrorCode, coerceIdForColumn, rawIdLiteral, viewBaseConfig } from "./util.js";
|
|
8
8
|
import { timestampWire } from "../timestamp-wire.js";
|
|
9
|
+
import { withDecimals } from "../decimal-wire.js";
|
|
9
10
|
import { withContractErrorHandler } from "./route-error-handler.js";
|
|
10
11
|
|
|
11
12
|
// biome-ignore lint/suspicious/noExplicitAny: dynamic dispatch over user-supplied views
|
|
@@ -20,7 +21,15 @@ export interface MountReadOnlyOptions {
|
|
|
20
21
|
readonly filterAllowlist: FilterAllowlist;
|
|
21
22
|
readonly sortAllowlist: SortAllowlist;
|
|
22
23
|
readonly dialect: SqlDialect;
|
|
23
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* The column `GET /:id` addresses a row by: the KEY of that column in the Drizzle view
|
|
26
|
+
* (the field name), not its physical name. Defaults to "id". For a view declared with
|
|
27
|
+
* no columns (the raw-SQL path) it is the physical column name instead, because there
|
|
28
|
+
* is no key to look up.
|
|
29
|
+
*
|
|
30
|
+
* A view that declares columns and has none under this key has no row to address, so
|
|
31
|
+
* `GET /:id` answers `404 not_found` for every id.
|
|
32
|
+
*/
|
|
24
33
|
readonly idColumn?: string;
|
|
25
34
|
/**
|
|
26
35
|
* Fastify route-level hooks applied to every route this mounts — the same option
|
|
@@ -33,15 +42,31 @@ export interface MountReadOnlyOptions {
|
|
|
33
42
|
* mount from an enclosing plugin scope works here too, and needs no option at all.)
|
|
34
43
|
*/
|
|
35
44
|
readonly routeOptions?: RouteShorthandOptions;
|
|
45
|
+
/**
|
|
46
|
+
* False for an object with no single-column primary identity: an `object.report`, or
|
|
47
|
+
* a keyless projection. Mounts the list route and the collection POST refusal only,
|
|
48
|
+
* and no `/:id` route of any verb. Default true, which is today's behaviour.
|
|
49
|
+
*/
|
|
50
|
+
readonly itemRoutes?: boolean;
|
|
51
|
+
/** The noun in the 405 message, which is free prose. Default "projection". */
|
|
52
|
+
readonly resource?: "projection" | "report";
|
|
53
|
+
/**
|
|
54
|
+
* The names of the view's `field.decimal` fields. A decimal is a canonical string on the wire
|
|
55
|
+
* on every dialect (ADR-0019 Amendment 1): Postgres and MySQL return the column's scale
|
|
56
|
+
* (`"100.5000"`), and SQLite hands back a number, a REAL for a computed one (a report's ratio,
|
|
57
|
+
* average or sum). Each row is rewritten under these keys. The generated route passes them;
|
|
58
|
+
* absent, rows are sent as read. Default none.
|
|
59
|
+
*/
|
|
60
|
+
readonly decimalColumns?: readonly string[];
|
|
36
61
|
}
|
|
37
62
|
|
|
38
|
-
const
|
|
63
|
+
const rejectMutation = (resource: string) => async (
|
|
39
64
|
request: { method: string },
|
|
40
65
|
reply: { code: (n: number) => { send: (b: unknown) => unknown } },
|
|
41
66
|
) => {
|
|
42
67
|
reply
|
|
43
68
|
.code(405)
|
|
44
|
-
.send({ error: "method_not_allowed", message: `${request.method} is not supported on a
|
|
69
|
+
.send({ error: "method_not_allowed", message: `${request.method} is not supported on a ${resource} (read-only).` });
|
|
45
70
|
};
|
|
46
71
|
|
|
47
72
|
function resolveViewName(view: AnyView): string | undefined {
|
|
@@ -128,11 +153,14 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
|
|
|
128
153
|
// Route-scoped contract error handler: an unexpected error answers
|
|
129
154
|
// `500 { error: "internal" }` rather than Fastify's default (which echoes the SQL).
|
|
130
155
|
const ro = withContractErrorHandler(opts.routeOptions);
|
|
156
|
+
const reject = rejectMutation(opts.resource ?? "projection");
|
|
157
|
+
const itemRoutes = opts.itemRoutes !== false;
|
|
131
158
|
|
|
132
159
|
const viewName = resolveViewName(view);
|
|
133
160
|
const useRawSql = isEmptyColumnView(view) && !!viewName;
|
|
134
161
|
// The raw-SQL branch has no declared columns, so nothing names a timestamp there.
|
|
135
|
-
const
|
|
162
|
+
const timestamps = timestampWire(view);
|
|
163
|
+
const toWire = withDecimals(timestamps, opts.decimalColumns);
|
|
136
164
|
|
|
137
165
|
// ── List ──────────────────────────────────────────────────────────────────
|
|
138
166
|
fastify.get(path, ro, async (req, reply) => {
|
|
@@ -205,37 +233,46 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
|
|
|
205
233
|
}
|
|
206
234
|
});
|
|
207
235
|
|
|
208
|
-
//
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
236
|
+
// A keyless object (`itemRoutes: false`) has nothing to address by id: no `/:id` route
|
|
237
|
+
// of any verb, so the framework's own 404 answers.
|
|
238
|
+
if (itemRoutes) {
|
|
239
|
+
// ── Get by ID ─────────────────────────────────────────────────────────────
|
|
240
|
+
fastify.get(`${path}/:id`, ro, async (req, reply) => {
|
|
241
|
+
const { id } = req.params as { id: string };
|
|
242
|
+
if (useRawSql) {
|
|
243
|
+
// biome-ignore lint/suspicious/noExplicitAny: dynamic raw result
|
|
244
|
+
const rows = await rawRows(db, dialect, sql.raw(`SELECT * FROM ${quoteIdent(dialect, viewName)} WHERE ${quoteIdent(dialect, idCol)} = ${rawIdLiteral(id)} LIMIT 1`)) as any[];
|
|
245
|
+
const row = rows[0] ? camelizeRow(rows[0]) : undefined;
|
|
246
|
+
return row ?? reply.code(404).send({ error: "not_found" });
|
|
247
|
+
}
|
|
248
|
+
// biome-ignore lint/suspicious/noExplicitAny: Drizzle table/view column ref
|
|
249
|
+
const colRef = (view as any)[idCol];
|
|
250
|
+
// No column under `idColumn`: there is nothing to compare the id against, and a query
|
|
251
|
+
// with no WHERE would answer with the view's first row. No row is addressable.
|
|
252
|
+
if (colRef === undefined) {
|
|
253
|
+
return reply.code(404).send({ error: "not_found" });
|
|
254
|
+
}
|
|
255
|
+
// Compare against the PK's real type — a uuid/text key must NOT go through Number().
|
|
256
|
+
const idValue = coerceIdForColumn(colRef, id);
|
|
257
|
+
if (idValue === undefined) {
|
|
258
|
+
return reply.code(400).send({ error: "invalid_id" });
|
|
259
|
+
}
|
|
260
|
+
// Await + first row rather than `.get()` (libsql/better-sqlite3-only).
|
|
261
|
+
const rows = await db.select().from(view).where(eq(colRef, idValue)).limit(1);
|
|
262
|
+
const row = (rows as unknown[])[0];
|
|
263
|
+
return row ? toWire(row) : reply.code(404).send({ error: "not_found" });
|
|
264
|
+
});
|
|
265
|
+
}
|
|
231
266
|
|
|
232
267
|
// ── Mutations explicitly rejected (405) ───────────────────────────────────
|
|
233
268
|
// PUT is here because the WRITABLE mount serves it (an alias of PATCH), so a
|
|
234
269
|
// projection must reject it the same way the other three are rejected. Omitting it
|
|
235
270
|
// left `PUT /<collection>/:id` falling through to Fastify's 404 — telling a caller
|
|
236
271
|
// the resource does not exist when it plainly does and answers GET.
|
|
237
|
-
fastify.post(path, ro,
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
272
|
+
fastify.post(path, ro, reject);
|
|
273
|
+
if (itemRoutes) {
|
|
274
|
+
fastify.patch(`${path}/:id`, ro, reject);
|
|
275
|
+
fastify.put(`${path}/:id`, ro, reject);
|
|
276
|
+
fastify.delete(`${path}/:id`, ro, reject);
|
|
277
|
+
}
|
|
241
278
|
}
|
package/src/extract-object.ts
CHANGED
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
isMetaRoot,
|
|
31
31
|
type MetaData,
|
|
32
32
|
TYPE_OBJECT,
|
|
33
|
-
|
|
33
|
+
refTarget,
|
|
34
34
|
FIELD_SUBTYPE_ENUM,
|
|
35
35
|
FIELD_SUBTYPE_OBJECT,
|
|
36
36
|
FIELD_SUBTYPE_STRING,
|
|
@@ -378,10 +378,9 @@ function attrString(node: MetaData, attr: string): string | null {
|
|
|
378
378
|
}
|
|
379
379
|
|
|
380
380
|
/**
|
|
381
|
-
* Resolve a field's `@objectRef` to its MetaObject
|
|
382
|
-
*
|
|
383
|
-
*
|
|
384
|
-
* package (`field.parent?.package ?? field.parent?.fileDefaultPackage`), else root-level.
|
|
381
|
+
* Resolve a field's `@objectRef` to its MetaObject: the target the loader bound
|
|
382
|
+
* (`refTarget` from `@metaobjectsdev/metadata`, ADR-0059 R7), resolved in the package of
|
|
383
|
+
* the node that declared the attribute, never by short name.
|
|
385
384
|
*
|
|
386
385
|
* #228: this previously called `MetaRoot.findObject(name)` directly — a bare-`name`-only
|
|
387
386
|
* lookup — and, for an FQN ref (which never matches any object's bare `.name`), fell back to
|
|
@@ -404,8 +403,7 @@ function resolveFieldObjectRef(field: MetaField): MetaObject | undefined {
|
|
|
404
403
|
// class check fails for a real root, so every field.object @objectRef falls to
|
|
405
404
|
// the opaque-leaf guard and extraction silently stops descending.
|
|
406
405
|
if (!isMetaRoot(root)) return undefined;
|
|
407
|
-
const
|
|
408
|
-
const { node } = resolveObjectRef(root, ref, referrerPkg);
|
|
406
|
+
const node = refTarget(field, FIELD_ATTR_OBJECT_REF);
|
|
409
407
|
return node !== undefined && node.type === TYPE_OBJECT ? (node as MetaObject) : undefined;
|
|
410
408
|
}
|
|
411
409
|
|
package/src/hono/index.ts
CHANGED
|
@@ -45,6 +45,7 @@ export type {
|
|
|
45
45
|
import { isTruthyFlag, coerceIdForColumn, firstRow, extractRowCount } from "../drizzle-fastify/util.js";
|
|
46
46
|
import { supportsReturning, type SqlDialect } from "../sql-dialect.js";
|
|
47
47
|
import { timestampWire } from "../timestamp-wire.js";
|
|
48
|
+
import { withDecimals } from "../decimal-wire.js";
|
|
48
49
|
// Every handler below is wrapped in guardRoute (an unexpected error answers
|
|
49
50
|
// `500 { error: "internal" }`, logged server-side) and reads its body through
|
|
50
51
|
// readJsonBody (a malformed body answers `400 { error: "invalid_json" }`).
|
|
@@ -53,6 +54,7 @@ import { guardRoute, readJsonBody } from "./route-guard.js";
|
|
|
53
54
|
// shared (deprecated) helper so the three adapters can't silently diverge.
|
|
54
55
|
export { parseId } from "../drizzle-fastify/util.js";
|
|
55
56
|
export { timestampWire, canonicalTimestamp } from "../timestamp-wire.js";
|
|
57
|
+
export { decimalWire, withDecimals, canonicalDecimal, parseDecimalInput, parseDecimalOperand } from "../decimal-wire.js";
|
|
56
58
|
|
|
57
59
|
// ---------------------------------------------------------------------------
|
|
58
60
|
// Loose types — we don't bind to a specific Drizzle backend so the helper
|
|
@@ -104,6 +106,13 @@ export interface CrudRoutesOptions {
|
|
|
104
106
|
updateMethod?: "patch" | "put";
|
|
105
107
|
filterAllowlist?: FilterAllowlist;
|
|
106
108
|
sortAllowlist?: SortAllowlist;
|
|
109
|
+
/**
|
|
110
|
+
* The names of the entity's `field.decimal` fields. A decimal is a canonical string on the
|
|
111
|
+
* wire on every dialect (ADR-0019 Amendment 1): Postgres and MySQL return the column's scale
|
|
112
|
+
* (`"100.5000"`) and SQLite hands back a number, so each row the routes send is rewritten
|
|
113
|
+
* under these keys. The generated route passes them; absent, rows are sent as read.
|
|
114
|
+
*/
|
|
115
|
+
decimalColumns?: readonly string[];
|
|
107
116
|
/** Dialect — required if filterAllowlist or sortAllowlist is set (for dialect-specific `like` lowering: SQLite lowers to GLOB to stay case-sensitive; ADR-0049). */
|
|
108
117
|
dialect?: SqlDialect;
|
|
109
118
|
}
|
|
@@ -150,7 +159,7 @@ function readSource(opts: VerbOptions): AnyTable {
|
|
|
150
159
|
* non-timestamp of the same name.
|
|
151
160
|
*/
|
|
152
161
|
function readWire(opts: VerbOptions): (row: unknown) => unknown {
|
|
153
|
-
return timestampWire(readSource(opts));
|
|
162
|
+
return withDecimals(timestampWire(readSource(opts)), opts.decimalColumns);
|
|
154
163
|
}
|
|
155
164
|
|
|
156
165
|
/**
|
|
@@ -159,7 +168,7 @@ function readWire(opts: VerbOptions): (row: unknown) => unknown {
|
|
|
159
168
|
* against the union of both sources.
|
|
160
169
|
*/
|
|
161
170
|
function echoWire(opts: VerbOptions): (row: unknown) => unknown {
|
|
162
|
-
return timestampWire(opts.table, opts.readView);
|
|
171
|
+
return withDecimals(timestampWire(opts.table, opts.readView), opts.decimalColumns);
|
|
163
172
|
}
|
|
164
173
|
|
|
165
174
|
/** Re-read a just-written row through the replica view so the response carries the
|
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
} from "../drizzle-fastify/filter-allowlist.js";
|
|
14
14
|
import { isTruthyFlag, coerceIdForColumn, rawIdLiteral, contractErrorCode, viewBaseConfig } from "../drizzle-fastify/util.js";
|
|
15
15
|
import { timestampWire } from "../timestamp-wire.js";
|
|
16
|
+
import { withDecimals } from "../decimal-wire.js";
|
|
16
17
|
// An unexpected error on a mounted route answers `500 { error: "internal" }`.
|
|
17
18
|
import { guardRoute } from "./route-guard.js";
|
|
18
19
|
|
|
@@ -30,8 +31,32 @@ export interface MountReadOnlyOptions {
|
|
|
30
31
|
readonly filterAllowlist: FilterAllowlist;
|
|
31
32
|
readonly sortAllowlist: SortAllowlist;
|
|
32
33
|
readonly dialect: SqlDialect;
|
|
33
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* The column `GET /:id` addresses a row by: the KEY of that column in the Drizzle view
|
|
36
|
+
* (the field name), not its physical name. Defaults to "id". For a view declared with
|
|
37
|
+
* no columns (the raw-SQL path) it is the physical column name instead, because there
|
|
38
|
+
* is no key to look up.
|
|
39
|
+
*
|
|
40
|
+
* A view that declares columns and has none under this key has no row to address, so
|
|
41
|
+
* `GET /:id` answers `404 not_found` for every id.
|
|
42
|
+
*/
|
|
34
43
|
readonly idColumn?: string;
|
|
44
|
+
/**
|
|
45
|
+
* False for an object with no single-column primary identity: an `object.report`, or
|
|
46
|
+
* a keyless projection. Mounts the list route and the collection POST refusal only,
|
|
47
|
+
* and no `/:id` route of any verb. Default true, which is today's behaviour.
|
|
48
|
+
*/
|
|
49
|
+
readonly itemRoutes?: boolean;
|
|
50
|
+
/** The noun in the 405 message, which is free prose. Default "projection". */
|
|
51
|
+
readonly resource?: "projection" | "report";
|
|
52
|
+
/**
|
|
53
|
+
* The names of the view's `field.decimal` fields. A decimal is a canonical string on the wire
|
|
54
|
+
* on every dialect (ADR-0019 Amendment 1): Postgres and MySQL return the column's scale
|
|
55
|
+
* (`"100.5000"`), and SQLite hands back a number, a REAL for a computed one (a report's ratio,
|
|
56
|
+
* average or sum). Each row is rewritten under these keys. The generated route passes them;
|
|
57
|
+
* absent, rows are sent as read. Default none.
|
|
58
|
+
*/
|
|
59
|
+
readonly decimalColumns?: readonly string[];
|
|
35
60
|
}
|
|
36
61
|
|
|
37
62
|
function resolveViewName(view: AnyView): string | undefined {
|
|
@@ -108,11 +133,13 @@ async function rawRows(db: any, dialect: string | undefined, query: unknown): Pr
|
|
|
108
133
|
export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
|
|
109
134
|
const { app, path, db, view, filterAllowlist, sortAllowlist, dialect } = opts;
|
|
110
135
|
const idCol = opts.idColumn ?? "id";
|
|
136
|
+
const itemRoutes = opts.itemRoutes !== false;
|
|
111
137
|
|
|
112
138
|
const viewName = resolveViewName(view);
|
|
113
139
|
const useRawSql = isEmptyColumnView(view) && !!viewName;
|
|
114
140
|
// The raw-SQL branch has no declared columns, so nothing names a timestamp there.
|
|
115
|
-
const
|
|
141
|
+
const timestamps = timestampWire(view);
|
|
142
|
+
const toWire = withDecimals(timestamps, opts.decimalColumns);
|
|
116
143
|
|
|
117
144
|
// ── List ──────────────────────────────────────────────────────────────────
|
|
118
145
|
app.get(path, guardRoute(async (c) => {
|
|
@@ -180,47 +207,59 @@ export function mountReadOnlyCrudRoutes(opts: MountReadOnlyOptions): void {
|
|
|
180
207
|
}
|
|
181
208
|
}));
|
|
182
209
|
|
|
183
|
-
//
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
210
|
+
// A keyless object (`itemRoutes: false`) has nothing to address by id: no `/:id` route
|
|
211
|
+
// of any verb, so the framework's own 404 answers.
|
|
212
|
+
if (itemRoutes) {
|
|
213
|
+
// ── Get by ID ─────────────────────────────────────────────────────────────
|
|
214
|
+
app.get(`${path}/:id`, guardRoute(async (c) => {
|
|
215
|
+
const id = c.req.param("id") ?? "";
|
|
216
|
+
if (useRawSql) {
|
|
217
|
+
// biome-ignore lint/suspicious/noExplicitAny: dynamic raw result
|
|
218
|
+
const rows = (await rawRows(db, dialect, sql.raw(`SELECT * FROM ${quoteIdent(dialect, viewName)} WHERE ${quoteIdent(dialect, idCol)} = ${rawIdLiteral(id)} LIMIT 1`))) as any[];
|
|
219
|
+
const row = rows[0] ? camelizeRow(rows[0]) : undefined;
|
|
220
|
+
return row ? c.json(row) : c.json({ error: "not_found" }, 404);
|
|
221
|
+
}
|
|
222
|
+
// biome-ignore lint/suspicious/noExplicitAny: Drizzle view column ref
|
|
223
|
+
const colRef = (view as any)[idCol];
|
|
224
|
+
// No column under `idColumn`: there is nothing to compare the id against, and a query
|
|
225
|
+
// with no WHERE would answer with the view's first row. No row is addressable.
|
|
226
|
+
if (colRef === undefined) {
|
|
227
|
+
return c.json({ error: "not_found" }, 404);
|
|
228
|
+
}
|
|
229
|
+
// Compare against the PK's real type — a uuid/text key must NOT go through Number().
|
|
230
|
+
const idValue = coerceIdForColumn(colRef, id);
|
|
231
|
+
if (idValue === undefined) {
|
|
232
|
+
return c.json({ error: "invalid_id" }, 400);
|
|
233
|
+
}
|
|
234
|
+
// `.get()` is likewise libsql/better-sqlite3-only; `.limit(1)` + await + [0]
|
|
235
|
+
// is the portable single-row read (#286).
|
|
236
|
+
const rows = await db
|
|
237
|
+
.select()
|
|
238
|
+
.from(view)
|
|
239
|
+
.where(eq(colRef, idValue))
|
|
240
|
+
.limit(1);
|
|
241
|
+
const row = (rows as unknown[])[0];
|
|
242
|
+
return row ? c.json(toWire(row)) : c.json({ error: "not_found" }, 404);
|
|
243
|
+
}));
|
|
244
|
+
}
|
|
209
245
|
|
|
210
246
|
// ── Mutations explicitly rejected (405) ───────────────────────────────────
|
|
247
|
+
const resource = opts.resource ?? "projection";
|
|
211
248
|
const reject = (c: { req: { method: string }; json: (body: unknown, status: number) => unknown }) =>
|
|
212
249
|
c.json(
|
|
213
|
-
{ error: "method_not_allowed", message: `${c.req.method} is not supported on a
|
|
250
|
+
{ error: "method_not_allowed", message: `${c.req.method} is not supported on a ${resource} (read-only).` },
|
|
214
251
|
405,
|
|
215
252
|
);
|
|
216
253
|
// biome-ignore lint/suspicious/noExplicitAny: cross-version Hono typing
|
|
217
254
|
app.post(path, reject as any);
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
255
|
+
if (itemRoutes) {
|
|
256
|
+
// biome-ignore lint/suspicious/noExplicitAny: cross-version Hono typing
|
|
257
|
+
app.patch(`${path}/:id`, reject as any);
|
|
258
|
+
// PUT too — the writable mount serves it, so a projection must reject it rather
|
|
259
|
+
// than 404, which would deny a resource that answers GET on the same path.
|
|
260
|
+
// biome-ignore lint/suspicious/noExplicitAny: cross-version Hono typing
|
|
261
|
+
app.put(`${path}/:id`, reject as any);
|
|
262
|
+
// biome-ignore lint/suspicious/noExplicitAny: cross-version Hono typing
|
|
263
|
+
app.delete(`${path}/:id`, reject as any);
|
|
264
|
+
}
|
|
226
265
|
}
|
package/src/index.ts
CHANGED
|
@@ -12,7 +12,8 @@ export type { Filter, FilterValue, QueryOpts } from "./query-builder.js";
|
|
|
12
12
|
|
|
13
13
|
export type { FieldViewSpec, EntityViewSpec } from "./view.js";
|
|
14
14
|
|
|
15
|
-
export
|
|
15
|
+
export { runValidators } from "./validator-runner.js";
|
|
16
|
+
export type { ValidationResult, RunValidatorsOpts } from "./validator-runner.js";
|
|
16
17
|
export type { ValidationFailure } from "./errors.js";
|
|
17
18
|
export {
|
|
18
19
|
RuntimeError,
|
package/src/n2m-resolver.ts
CHANGED
|
@@ -22,6 +22,7 @@ import type { ColumnNamingStrategy, MetaData, MetaObject, MetaRoot } from "@meta
|
|
|
22
22
|
import {
|
|
23
23
|
TYPE_OBJECT, TYPE_FIELD, TYPE_RELATIONSHIP,
|
|
24
24
|
RELATIONSHIP_ATTR_CARDINALITY, RELATIONSHIP_ATTR_OBJECT_REF, RELATIONSHIP_ATTR_THROUGH,
|
|
25
|
+
refTarget,
|
|
25
26
|
CARDINALITY_MANY,
|
|
26
27
|
DEFAULT_COLUMN_NAMING_STRATEGY,
|
|
27
28
|
resolveColumnName,
|
|
@@ -68,8 +69,9 @@ export function resolveN2mDescriptor(
|
|
|
68
69
|
if (child.attr(RELATIONSHIP_ATTR_THROUGH) === undefined) continue; // 1:N many — not M:N.
|
|
69
70
|
|
|
70
71
|
const rel = child as MetaRelationship;
|
|
71
|
-
|
|
72
|
-
const
|
|
72
|
+
// ADR-0059 R7: the loader bound @objectRef/@through once; read those targets.
|
|
73
|
+
const targetEntityName = refTarget(rel, RELATIONSHIP_ATTR_OBJECT_REF)?.name;
|
|
74
|
+
const joinEntityName = refTarget(rel, RELATIONSHIP_ATTR_THROUGH)?.name;
|
|
73
75
|
if (!targetEntityName || !joinEntityName) {
|
|
74
76
|
throw new MetadataError(
|
|
75
77
|
`M:N relationship '${relationName}' on '${sourceEntity.name}' requires @objectRef + @through`,
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Run-time format checks for `field.uri` and `field.inet` (#234, ADR-0037 — Contract A).
|
|
2
|
+
//
|
|
3
|
+
// These are deliberately explicit, engine-neutral rules rather than `new URL()` or a
|
|
4
|
+
// platform IP parser: the Python `run_validators` runner applies the SAME patterns, so the
|
|
5
|
+
// two run-time runners agree on every input and not only on the pinned
|
|
6
|
+
// validation-conformance probe set.
|
|
7
|
+
|
|
8
|
+
// The IPv4 and IPv6 literal patterns the generated Zod schema uses
|
|
9
|
+
// (`codegen-ts/src/templates/net-regex.ts`) — no hostnames, no CIDR, no padding, no
|
|
10
|
+
// leading-zero octet; IPv6 includes the embedded-IPv4 tails.
|
|
11
|
+
const IPV4_RE =
|
|
12
|
+
/^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$/;
|
|
13
|
+
const IPV6_RE =
|
|
14
|
+
/^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|::(ffff(:0{1,4})?:)?((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9]))$/;
|
|
15
|
+
|
|
16
|
+
/** True when `value` is an IPv4 or IPv6 literal. */
|
|
17
|
+
export function isInetLiteral(value: string): boolean {
|
|
18
|
+
return IPV4_RE.test(value) || IPV6_RE.test(value);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Leading/trailing C0 controls and spaces, which a URL parser strips before parsing.
|
|
22
|
+
const URI_PAD_RE = /^[\u0000- ]+|[\u0000- ]+$/g;
|
|
23
|
+
// scheme ":" remainder — the remainder may hold anything, including a newline.
|
|
24
|
+
const URI_SCHEME_RE = /^[A-Za-z][A-Za-z0-9+.-]*:([\s\S]*)$/;
|
|
25
|
+
const URI_AUTHORITY_END_RE = /[/?#]/;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* True when `value` is an absolute URI: a scheme, a non-empty remainder and — when the
|
|
29
|
+
* remainder opens an authority with `//` — a non-empty authority. Padding is stripped first.
|
|
30
|
+
*/
|
|
31
|
+
export function isAbsoluteUri(value: string): boolean {
|
|
32
|
+
const rest = URI_SCHEME_RE.exec(value.replace(URI_PAD_RE, ""))?.[1];
|
|
33
|
+
if (rest === undefined || rest.length === 0) return false;
|
|
34
|
+
if (!rest.startsWith("//")) return true;
|
|
35
|
+
const authority = rest.slice(2).split(URI_AUTHORITY_END_RE)[0] ?? "";
|
|
36
|
+
return authority.length > 0;
|
|
37
|
+
}
|