diff --git a/src/proxy/server.ts b/src/proxy/server.ts index 7d8951c8..d7d67657 100644 --- a/src/proxy/server.ts +++ b/src/proxy/server.ts @@ -29,6 +29,7 @@ import { ProxyTokenTooLargeError, } from "./token.js"; import { ResponseTooLargeError, ServiceResponseError } from "../helpers/http.js"; +import { CqlFilterTooLargeError } from "../wfs/spatialCql.js"; import { ZodError } from "zod"; // --- Error → HTTP status mapping --- @@ -74,6 +75,9 @@ function toHttpError(error: unknown): HttpError { // contract (duplicate / id mismatch / unusable body): an upstream anomaly. return { status: 502, detail: "Le service WFS a renvoyé une réponse incohérente pour cet objet." }; } + if (error instanceof CqlFilterTooLargeError) { + return { status: 413, detail: "La couche demandée exige un filtrage spatial trop lourd pour être calculé." }; + } if (error instanceof ServiceResponseError) { const upstream = error.httpStatus ?? 502; // 4xx from the WFS is a bad request we forwarded; expose as 502 unless it is a diff --git a/src/wfs/catalog.ts b/src/wfs/catalog.ts index 3e89b857..7d386ce3 100644 --- a/src/wfs/catalog.ts +++ b/src/wfs/catalog.ts @@ -103,6 +103,11 @@ export class WfsSchemaStore { throw new FeatureTypeNotFoundError(name); } + /** Checks whether a typename exists in the embedded catalog without throwing for missing entries. */ + hasFeatureType(name: string): boolean { + return this.catalog.getCollectionSchema(name) !== undefined; + } + } // --- Default singleton --- diff --git a/src/wfs/queryPreparation.ts b/src/wfs/queryPreparation.ts index d3cc9a45..c2f0a4d5 100644 --- a/src/wfs/queryPreparation.ts +++ b/src/wfs/queryPreparation.ts @@ -39,6 +39,9 @@ import { compileDwithinSpatialFilter, compileIntersectsFeatureSpatialFilter, compileIntersectsPointSpatialFilter, + CqlFilterTooLargeError, + encodedCqlFilterBytes, + MAX_CQL_FILTER_BYTES, } from "./spatialCql.js"; import type { Geometry } from "geojson"; @@ -207,7 +210,9 @@ export function compileQueryParts( if (!resolvedGeometryRef) { throw new Error("Le filtre spatial `intersects_feature` exige la résolution préalable de la géométrie de référence."); } - fragments.push(compileIntersectsFeatureSpatialFilter(geometryName, resolvedGeometryRef)); + fragments.push( + compileIntersectsFeatureSpatialFilter(geometryName, resolvedGeometryRef, spatialFilter.typename), + ); break; case "travel_time": if (!resolvedGeometryRef) { @@ -226,6 +231,15 @@ export function compileQueryParts( } const cqlFilter = fragments.length > 0 ? fragments.join(" AND ") : undefined; + if (cqlFilter) { + const cqlFilterBytes = encodedCqlFilterBytes(cqlFilter); + if (cqlFilterBytes > MAX_CQL_FILTER_BYTES) { + throw new CqlFilterTooLargeError( + `Le filtre CQL est trop volumineux : ${cqlFilterBytes} octets une fois encodé (limite : ${MAX_CQL_FILTER_BYTES}). ` + + "Réduire le nombre de clauses attributaires ou simplifier les filtres.", + ); + } + } if (!isGetFeatures) { // for CountFeatures: only return the required parts diff --git a/src/wfs/request.ts b/src/wfs/request.ts index e8f1af3e..7dc171ed 100644 --- a/src/wfs/request.ts +++ b/src/wfs/request.ts @@ -9,6 +9,7 @@ import { GPF_WFS_URL } from "./catalog.js"; import type { CompiledQuery } from "./queryPreparation.js"; import { queryIsGetFeaturesInput, type GpfQueryFeaturesInput } from "./schema.js"; +import { CqlFilterTooLargeError, MAX_CQL_FILTER_BYTES } from "./spatialCql.js"; // --- Transport Types --- @@ -26,12 +27,20 @@ export type CompiledRequest = { * * @param cqlFilter Compiled CQL filter, when the request needs one. * @returns The encoded POST body, or an empty string when no filter is present. + * @throws When the body exceeds `MAX_CQL_FILTER_BYTES`, since the WFS would silently ignore the filter. */ function buildBody(cqlFilter?: string) { if (!cqlFilter) { return ""; } - return new URLSearchParams({ cql_filter: cqlFilter }).toString(); + const body = new URLSearchParams({ cql_filter: cqlFilter }).toString(); + // The URL-encoded body is pure ASCII: its length is its size in bytes. + if (body.length > MAX_CQL_FILTER_BYTES) { + throw new CqlFilterTooLargeError( + `Le filtre CQL est trop volumineux : ${body.length} octets une fois encodé (limite : ${MAX_CQL_FILTER_BYTES}).`, + ); + } + return body; } // --- Public Builders --- @@ -164,14 +173,10 @@ export function buildMultiTypenameRequest( srsName: "EPSG:4326", }; - const body = expandedCqlFilter - ? new URLSearchParams({ cql_filter: expandedCqlFilter }).toString() - : ""; - return { method: "POST", url: GPF_WFS_URL, query, - body, + body: buildBody(expandedCqlFilter), }; } diff --git a/src/wfs/spatialCql.ts b/src/wfs/spatialCql.ts index 17bbba9d..b17febff 100644 --- a/src/wfs/spatialCql.ts +++ b/src/wfs/spatialCql.ts @@ -8,6 +8,41 @@ import type { Geometry } from "geojson"; import type { SpatialFilter } from "./schema.js"; import { geometryToEwkt } from "./geometry.js"; +import { wfsSchemaStore } from "./catalog.js"; + +/** + * Maximum size of the URL-encoded `cql_filter=...` POST body accepted by the Géoplateforme WFS. + * This matches Tomcat's default `maxPostSize` (a server setting that can change); beyond it, + * the server silently ignores form parameters, so it returns the whole layer (measured on 2026-10-13). + */ +export const MAX_CQL_FILTER_BYTES = 2 * 1024 * 1024; + +export class CqlFilterTooLargeError extends Error { + constructor(message: string) { + super(message); + this.name = "CqlFilterTooLargeError"; + } +} + +/** + * Computes the size of the POST body carrying a CQL filter, to compare with `MAX_CQL_FILTER_BYTES`. + * + * @param cqlFilter Compiled CQL filter. + * @returns The size in bytes of the URL-encoded body (pure ASCII, so its length is its byte size). + */ +export function encodedCqlFilterBytes(cqlFilter: string) { + return new URLSearchParams({ cql_filter: cqlFilter }).toString().length; +} + +/** Returns the same-version CARTO-PE twin only when its derived typename exists in the catalog. */ +function toSmallScaleTypename(typename: string): string | undefined { + const match = /^ADMINEXPRESS-COG\.([^:.]+):(.+)$/.exec(typename); + if (!match) { + return undefined; + } + const candidate = `ADMINEXPRESS-COG-CARTO-PE.${match[1]}:${match[2]}`; + return wfsSchemaStore.hasFeatureType(candidate) ? candidate : undefined; +} // --- Spatial Predicate Compilation --- @@ -51,12 +86,44 @@ export function compileDwithinSpatialFilter(geometryName: string, spatialFilter: } /** - * Compiles an `intersects_feature` spatial filter from the resolved reference geometry. + * Compiles a spatial filter from the intersection with a resolved reference geometry. + * Used in `intersects_feature` and `travel_time` filters. + * Throw if the filter is too big. * * @param geometryName Geometry property already resolved for the feature type. * @param geometry Reference geometry. + * @param referenceTypename Typename of the reference feature for `intersects_feature`, nothing for `travel_time`. Used for error diagnostic. * @returns A CQL intersects predicate. + * @throws When the predicate exceeds the budget. */ -export function compileIntersectsFeatureSpatialFilter(geometryName: string, geometry: Geometry) { - return `INTERSECTS(${geometryName},${geometryToEwkt(geometry)})`; +export function compileIntersectsFeatureSpatialFilter( + geometryName: string, + geometry: Geometry, + referenceTypename?: string, +) { + const exactFilter = `INTERSECTS(${geometryName},${geometryToEwkt(geometry)})`; + const filterBytes = encodedCqlFilterBytes(exactFilter); + if (filterBytes <= MAX_CQL_FILTER_BYTES) { + return exactFilter; + } + + const recommendation = (() => { + if (!referenceTypename) { + return "Réduire la taille de l'isochrone."; + } + const peTwin = toSmallScaleTypename(referenceTypename); + if (peTwin) { + return `Choisir une géométrie de référence moins détaillée : privilégier la couche '${peTwin}' au lieu de '${referenceTypename}'. Les feature_id diffèrent entre ces deux couches : ne pas réutiliser le même identifiant, retrouver l'objet dans '${peTwin}' par sa propriété cleabs (identique dans les deux couches).`; + } + if (referenceTypename.includes("CARTO-PE")) { + return "Choisir une géométrie de référence moins détaillée."; + } + return "Choisir une géométrie de référence moins détaillée. Utiliser le tool gpf_search_types pour trouver une couche adaptée et disponible dans le catalogue."; + })(); + + const filterName = referenceTypename ? "La géométrie de référence" : "L'isochrone"; + throw new CqlFilterTooLargeError([ + `${filterName} est trop volumineuse pour \`${referenceTypename ? "intersects_feature" : "travel_time"}_filter\` : ${filterBytes} octets de filtre encodé (limite : ${MAX_CQL_FILTER_BYTES}).`, + recommendation, + ].join("\n")); } diff --git a/test/proxy/server.test.ts b/test/proxy/server.test.ts index 5eb89af8..11a10fa5 100644 --- a/test/proxy/server.test.ts +++ b/test/proxy/server.test.ts @@ -7,6 +7,7 @@ import { encodeToken } from "../../src/proxy/token"; import { resetEnv } from "../../src/config/env"; import { PROXY_TOKEN_KIND } from "../../src/wfs/schema"; import { FeatureNotFoundError, FeatureCardinalityError } from "../../src/wfs/byId"; +import { CqlFilterTooLargeError } from "../../src/wfs/spatialCql"; import { ServiceResponseError, ResponseTooLargeError } from "../../src/helpers/http"; // Mock the proxy engine + transport so the server is exercised WITHOUT network. @@ -119,6 +120,16 @@ describe("proxy/server", () => { expect(res.text).not.toContain("upstream timed out"); }); + it("413 on a CQL filter built from an oversized isochrone", async () => { + const detail = "La couche demandée exige un filtrage spatial trop lourd pour être calculé."; + runGeometryFeatureQuery.mockRejectedValue(new CqlFilterTooLargeError(detail)); + + const res = await request(baseUrl).get(layerPath(validToken())); + + expect(res.status).toBe(413); + expect(res.body).toEqual({ error: true, detail }); + }); + it("500 on an unexpected (non-typed) error", async () => { runGeometryFeatureQuery.mockRejectedValue(new Error("boom")); const res = await request(baseUrl).get(layerPath(validToken())); diff --git a/test/wfs/catalog.test.ts b/test/wfs/catalog.test.ts index 5dac01eb..5800bc7e 100644 --- a/test/wfs/catalog.test.ts +++ b/test/wfs/catalog.test.ts @@ -137,6 +137,16 @@ describe("Test WfsSchemaStore",() => { }); }); + describe("hasFeatureType", () => { + it("should recognize an existing catalog entry", () => { + expect(wfsSchemaStore.hasFeatureType("BDTOPO_V3:batiment")).toBe(true); + }); + + it("should return false for a missing entry without throwing", () => { + expect(wfsSchemaStore.hasFeatureType("BDTOPO_V3:not_found")).toBe(false); + }); + }); + describe("getFeatureType",() => { it("should return the feature type with BDTOPO_V3:batiment", async () => { const featureType = await wfsSchemaStore.getFeatureType("BDTOPO_V3:batiment"); diff --git a/test/wfs/queryPreparation.test.ts b/test/wfs/queryPreparation.test.ts index eda7587d..8c4fcac1 100644 --- a/test/wfs/queryPreparation.test.ts +++ b/test/wfs/queryPreparation.test.ts @@ -215,6 +215,15 @@ describe("gpfGetFeatures/queryPreparation", () => { ); }); + it("should reject queries when combined CQL filter exceeds MAX_CQL_FILTER_BYTES once URL-encoded", () => { + // ~1.6 MB as raw text, ~2.3 MB once URL-encoded + const hugeWhereValues = Array.from({ length: 120_000 }, (_, i) => `val_${i}`); + expect(() => compileQueryParts({ + ...baseInput, + where: [{ property: "code_insee", operator: "in", values: hugeWhereValues }], + }, wrappedFeatureType)).toThrow(/Le filtre CQL est trop volumineux/); + }); + it("should build sortBy from structured order_by", () => { const compiled = compileQueryParts({ ...baseInput, diff --git a/test/wfs/referenceGeometry.test.ts b/test/wfs/referenceGeometry.test.ts new file mode 100644 index 00000000..5bcc948c --- /dev/null +++ b/test/wfs/referenceGeometry.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { GpfFeatureType } from "../../src/wfs/catalog.js"; +import type { CompiledRequest } from "../../src/wfs/request.js"; +import { + resolveFeatureGeometry, + type ReferenceGeometryClient, +} from "../../src/wfs/referenceGeometry.js"; + +const geometry = { type: "Point", coordinates: [2, 48] } as const; + +function featureType(typename: string): GpfFeatureType { + return { + typename, + schema: { properties: { geom: { format: "geometry-point" }, nom: { type: "string" } } }, + } as unknown as GpfFeatureType; +} + +function makeClient(knownTypenames: string[]) { + const getFeatureType = vi.fn(async (typename: string) => { + if (!knownTypenames.includes(typename)) { + throw new Error(`unknown ${typename}`); + } + return featureType(typename); + }); + const fetchFeatureCollection = vi.fn(async (_request: CompiledRequest) => ({ + type: "FeatureCollection", + features: [{ type: "Feature", id: "commune.1", geometry, properties: {} }], + })); + const client = { getFeatureType, fetchFeatureCollection } as unknown as ReferenceGeometryClient; + return { client, getFeatureType, fetchFeatureCollection }; +} + +const ref = (typename: string) => ({ typename, feature_id: "commune.1" }); + +describe("resolveFeatureGeometry", () => { + it.each([ + "ADMINEXPRESS-COG.2025:commune", + "ADMINEXPRESS-COG.2026:commune", + "ADMINEXPRESS-COG.LATEST:commune", + "ADMINEXPRESS-COG.2024:commune", + "BDTOPO_V3:batiment", + ])("queries the requested typename %s directly without automatic remapping", async (typename) => { + const { client, getFeatureType, fetchFeatureCollection } = makeClient([typename]); + + await expect(resolveFeatureGeometry(client, ref(typename))).resolves.toEqual(geometry); + + expect(getFeatureType).toHaveBeenCalledTimes(1); + expect(getFeatureType).toHaveBeenCalledWith(typename); + expect(fetchFeatureCollection.mock.calls[0][0].query.typeNames).toBe(typename); + }); +}); diff --git a/test/wfs/request.test.ts b/test/wfs/request.test.ts index 5dc7fe17..7f4c199e 100644 --- a/test/wfs/request.test.ts +++ b/test/wfs/request.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "vitest"; import { buildMultiTypenameRequest } from "../../src/wfs/request"; +import { MAX_CQL_FILTER_BYTES } from "../../src/wfs/spatialCql"; + +/** Builds a `code_insee IN (...)` filter: under the limit as raw text, but ~1.7x larger once URL-encoded. */ +function inFilter(count: number) { + return `code_insee IN (${Array.from({ length: count }, (_, i) => `'${i}'`).join(",")})`; +} describe("wfs_engine/request", () => { it("should build a multi-typename request with encoded cql_filter", () => { @@ -90,4 +96,39 @@ describe("wfs_engine/request", () => { expect(request.body).toContain("cql_filter="); expect(request.body.length).toBeGreaterThan(6000); }); + + it("accepts a POST body of exactly MAX_CQL_FILTER_BYTES and rejects one byte more", () => { + const filterAtLimit = "a".repeat(MAX_CQL_FILTER_BYTES - "cql_filter=".length); + const request = buildMultiTypenameRequest({ + typenames: ["ADMINEXPRESS-COG.LATEST:commune"], + cqlFilter: filterAtLimit, + }); + expect(request.body.length).toBe(MAX_CQL_FILTER_BYTES); + + expect(() => buildMultiTypenameRequest({ + typenames: ["ADMINEXPRESS-COG.LATEST:commune"], + cqlFilter: `${filterAtLimit}a`, + })).toThrow(/Le filtre CQL est trop volumineux/); + }); + + it("rejects cqlFilter when its URL-encoded body exceeds MAX_CQL_FILTER_BYTES", () => { + const hugeFilter = inFilter(200_000); + expect(Buffer.byteLength(hugeFilter)).toBeLessThan(MAX_CQL_FILTER_BYTES); + expect(() => buildMultiTypenameRequest({ + typenames: ["ADMINEXPRESS-COG.LATEST:commune"], + cqlFilter: hugeFilter, + })).toThrow(/Le filtre CQL est trop volumineux/); + }); + + it("rejects multi-typename cqlFilters when expanded filter exceeds MAX_CQL_FILTER_BYTES", () => { + const largeFilter = inFilter(100_000); + expect(() => buildMultiTypenameRequest({ + typenames: ["ADMINEXPRESS-COG.LATEST:commune"], + cqlFilter: largeFilter, + })).not.toThrow(); + expect(() => buildMultiTypenameRequest({ + typenames: ["ADMINEXPRESS-COG.LATEST:commune", "ADMINEXPRESS-COG.LATEST:departement"], + cqlFilters: [largeFilter, largeFilter], + })).toThrow(/Le filtre CQL est trop volumineux/); + }); }); diff --git a/test/wfs/spatialCql.test.ts b/test/wfs/spatialCql.test.ts index de5607bf..48d54c78 100644 --- a/test/wfs/spatialCql.test.ts +++ b/test/wfs/spatialCql.test.ts @@ -1,13 +1,18 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { compileBboxSpatialFilter, compileIntersectsPointSpatialFilter, compileDwithinSpatialFilter, compileIntersectsFeatureSpatialFilter, + encodedCqlFilterBytes, + MAX_CQL_FILTER_BYTES, + CqlFilterTooLargeError, } from "../../src/wfs/spatialCql"; import type { SpatialFilter } from "../../src/wfs/schema"; +import { geometryToEwkt } from "../../src/wfs/geometry"; +import { wfsSchemaStore } from "../../src/wfs/catalog"; // --- Shared fixtures --- @@ -124,12 +129,22 @@ describe("compileDwithinSpatialFilter", () => { // --- compileIntersectsFeatureSpatialFilter --- describe("compileIntersectsFeatureSpatialFilter", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + // Fits under the limit as raw text, but not once URL-encoded (`(`, `)`, `,` take 3 bytes each). + const oversizedGeometry = { + type: "MultiPoint" as const, + coordinates: Array.from({ length: 120_000 }, (_, index) => [2 + index / 100_000, 48]), + }; + it("should compile an intersects_feature filter with EWKT geometry", () => { const geometry = { type: "MultiPolygon" as const, coordinates: [[[[2, 48], [2.2, 48], [2.2, 48.2], [2, 48]]]] }; - const result = compileIntersectsFeatureSpatialFilter(geometryName, geometry); + const result = compileIntersectsFeatureSpatialFilter(geometryName, geometry, "TYPENAME"); expect(result).toEqual("INTERSECTS(the_geom,SRID=4326;MULTIPOLYGON(((2 48,2.2 48,2.2 48.2,2 48))))"); }); @@ -137,10 +152,93 @@ describe("compileIntersectsFeatureSpatialFilter", () => { it("should compile with a POINT EWKT", () => { const geometry = { type: "Point" as const, coordinates: [2.3522, 48.8566] } - const result = compileIntersectsFeatureSpatialFilter(geometryName, geometry); + const result = compileIntersectsFeatureSpatialFilter(geometryName, geometry, "TYPENAME"); expect(result).toEqual("INTERSECTS(the_geom,SRID=4326;POINT(2.3522 48.8566))"); }); + + it("should preserve small filter geometries even when their vertices could be simplified", () => { + const geometry = { + type: "LineString" as const, + coordinates: [[2, 48], [2.1, 48.00001], [2.2, 48]], + }; + + expect(compileIntersectsFeatureSpatialFilter(geometryName, geometry, "TYPENAME")).toEqual( + `INTERSECTS(the_geom,${geometryToEwkt(geometry)})`, + ); + }); + + it("should reject filter geometries whose URL-encoded filter is oversized", () => { + const filter = `INTERSECTS(the_geom,${geometryToEwkt(oversizedGeometry)})`; + expect(Buffer.byteLength(filter)).toBeLessThan(MAX_CQL_FILTER_BYTES); + expect(encodedCqlFilterBytes(filter)).toBeGreaterThan(MAX_CQL_FILTER_BYTES); + expect(() => compileIntersectsFeatureSpatialFilter(geometryName, oversizedGeometry, "TYPENAME")).toThrow( + /géométrie.*trop volumineuse.*/, + ); + }); + + it("should identify an oversized isochrone and recommend choosing a smaller one", () => { + const compile = () => compileIntersectsFeatureSpatialFilter(geometryName, oversizedGeometry); + + expect(compile).toThrow(CqlFilterTooLargeError); + expect(compile).toThrow("travel_time_filter"); + expect(compile).toThrow("Réduire la taille de l'isochrone."); + }); + + it.each(["LATEST", "2025", "2026", "2027", "FUTURE"])( + "should suggest a catalog-confirmed same-version twin for %s", + (version) => { + const candidate = `ADMINEXPRESS-COG-CARTO-PE.${version}:departement`; + const hasFeatureType = vi.spyOn(wfsSchemaStore, "hasFeatureType") + .mockImplementation((typename) => typename === candidate); + + expect(() => + compileIntersectsFeatureSpatialFilter(geometryName, oversizedGeometry, `ADMINEXPRESS-COG.${version}:departement`), + ).toThrow(candidate); + expect(hasFeatureType).toHaveBeenCalledWith(candidate); + }, + ); + + it("should suggest the corresponding -CARTO-PE layer from the embedded catalog", () => { + expect(() => + compileIntersectsFeatureSpatialFilter(geometryName, oversizedGeometry, "ADMINEXPRESS-COG.LATEST:departement"), + ).toThrow(/ADMINEXPRESS-COG-CARTO-PE\.LATEST:departement/); + }); + + it.each([ + "ADMINEXPRESS-COG.1099:departement", + "ADMINEXPRESS-COG.LATEST:missing_layer", + ])("should not recommend an absent twin for %s", (typename) => { + vi.spyOn(wfsSchemaStore, "hasFeatureType").mockReturnValue(false); + const compile = () => compileIntersectsFeatureSpatialFilter(geometryName, oversizedGeometry, typename); + + expect(compile).toThrow(/gpf_search_types/); + expect(compile).not.toThrow(/ADMINEXPRESS-COG-CARTO-PE/); + }); + + it("should suggest catalog search when reference has no known naming convention", () => { + const hasFeatureType = vi.spyOn(wfsSchemaStore, "hasFeatureType"); + const compile = () => compileIntersectsFeatureSpatialFilter(geometryName, oversizedGeometry, "BDTOPO_V3:batiment"); + + expect(compile).toThrow(/gpf_search_types/); + expect(compile).not.toThrow(/ADMINEXPRESS-COG-CARTO-PE/); + expect(hasFeatureType).not.toHaveBeenCalled(); + }); + + it("should not recommend -CARTO-PE when already using a -CARTO-PE reference layer", () => { + const compile = () => compileIntersectsFeatureSpatialFilter(geometryName, oversizedGeometry, "ADMINEXPRESS-COG-CARTO-PE.LATEST:departement"); + expect(compile).toThrow(); + expect(compile).not.toThrow(/ADMINEXPRESS-COG-CARTO-PE/); + }); + + it("should not look up a twin when the geometry fits within the limit", () => { + const hasFeatureType = vi.spyOn(wfsSchemaStore, "hasFeatureType"); + const geometry = { type: "Point" as const, coordinates: [2, 48] }; + + expect(compileIntersectsFeatureSpatialFilter(geometryName, geometry, "ADMINEXPRESS-COG.LATEST:departement")) + .toEqual("INTERSECTS(the_geom,SRID=4326;POINT(2 48))"); + expect(hasFeatureType).not.toHaveBeenCalled(); + }); }); // --- Helpers ---