@entifix/next-shell 0.1.0-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/LICENSE +21 -0
- package/README.md +7 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/back-office/account-menu.d.ts +29 -0
- package/dist/lib/back-office/account-menu.d.ts.map +1 -0
- package/dist/lib/back-office/account-menu.js +70 -0
- package/dist/lib/back-office/account-menu.js.map +1 -0
- package/dist/lib/back-office/back-office-shell.d.ts +50 -0
- package/dist/lib/back-office/back-office-shell.d.ts.map +1 -0
- package/dist/lib/back-office/back-office-shell.js +128 -0
- package/dist/lib/back-office/back-office-shell.js.map +1 -0
- package/dist/lib/back-office/breadcrumbs.d.ts +16 -0
- package/dist/lib/back-office/breadcrumbs.d.ts.map +1 -0
- package/dist/lib/back-office/breadcrumbs.js +53 -0
- package/dist/lib/back-office/breadcrumbs.js.map +1 -0
- package/dist/lib/back-office/group-by-screen-type.d.ts +21 -0
- package/dist/lib/back-office/group-by-screen-type.d.ts.map +1 -0
- package/dist/lib/back-office/group-by-screen-type.js +39 -0
- package/dist/lib/back-office/group-by-screen-type.js.map +1 -0
- package/dist/lib/back-office/index.d.ts +6 -0
- package/dist/lib/back-office/index.d.ts.map +1 -0
- package/dist/lib/back-office/index.js +7 -0
- package/dist/lib/back-office/index.js.map +1 -0
- package/dist/lib/back-office/nav.d.ts +39 -0
- package/dist/lib/back-office/nav.d.ts.map +1 -0
- package/dist/lib/back-office/nav.js +3 -0
- package/dist/lib/back-office/nav.js.map +1 -0
- package/dist/lib/back-office/sidebar-nav.d.ts +26 -0
- package/dist/lib/back-office/sidebar-nav.d.ts.map +1 -0
- package/dist/lib/back-office/sidebar-nav.js +181 -0
- package/dist/lib/back-office/sidebar-nav.js.map +1 -0
- package/dist/lib/command-palette/command-palette-host.d.ts +41 -0
- package/dist/lib/command-palette/command-palette-host.d.ts.map +1 -0
- package/dist/lib/command-palette/command-palette-host.js +196 -0
- package/dist/lib/command-palette/command-palette-host.js.map +1 -0
- package/dist/lib/command-palette/index.d.ts +9 -0
- package/dist/lib/command-palette/index.d.ts.map +1 -0
- package/dist/lib/command-palette/index.js +10 -0
- package/dist/lib/command-palette/index.js.map +1 -0
- package/dist/lib/command-palette/palette-command.d.ts +32 -0
- package/dist/lib/command-palette/palette-command.d.ts.map +1 -0
- package/dist/lib/command-palette/palette-command.js +19 -0
- package/dist/lib/command-palette/palette-command.js.map +1 -0
- package/dist/lib/command-palette/use-command-recency.d.ts +28 -0
- package/dist/lib/command-palette/use-command-recency.d.ts.map +1 -0
- package/dist/lib/command-palette/use-command-recency.js +67 -0
- package/dist/lib/command-palette/use-command-recency.js.map +1 -0
- package/dist/lib/command-palette/use-command-source.d.ts +17 -0
- package/dist/lib/command-palette/use-command-source.d.ts.map +1 -0
- package/dist/lib/command-palette/use-command-source.js +70 -0
- package/dist/lib/command-palette/use-command-source.js.map +1 -0
- package/dist/lib/command-palette/use-nav-source.d.ts +21 -0
- package/dist/lib/command-palette/use-nav-source.d.ts.map +1 -0
- package/dist/lib/command-palette/use-nav-source.js +78 -0
- package/dist/lib/command-palette/use-nav-source.js.map +1 -0
- package/dist/lib/command-palette/use-records-source.d.ts +23 -0
- package/dist/lib/command-palette/use-records-source.d.ts.map +1 -0
- package/dist/lib/command-palette/use-records-source.js +199 -0
- package/dist/lib/command-palette/use-records-source.js.map +1 -0
- package/dist/lib/command-palette/use-tabs-source.d.ts +20 -0
- package/dist/lib/command-palette/use-tabs-source.d.ts.map +1 -0
- package/dist/lib/command-palette/use-tabs-source.js +52 -0
- package/dist/lib/command-palette/use-tabs-source.js.map +1 -0
- package/dist/lib/command-palette/use-use-cases-source.d.ts +22 -0
- package/dist/lib/command-palette/use-use-cases-source.d.ts.map +1 -0
- package/dist/lib/command-palette/use-use-cases-source.js +93 -0
- package/dist/lib/command-palette/use-use-cases-source.js.map +1 -0
- package/dist/lib/config/config-route.d.ts +51 -0
- package/dist/lib/config/config-route.d.ts.map +1 -0
- package/dist/lib/config/config-route.js +65 -0
- package/dist/lib/config/config-route.js.map +1 -0
- package/dist/lib/config/service-proxy-route.d.ts +28 -0
- package/dist/lib/config/service-proxy-route.d.ts.map +1 -0
- package/dist/lib/config/service-proxy-route.js +125 -0
- package/dist/lib/config/service-proxy-route.js.map +1 -0
- package/dist/lib/config/service-token.d.ts +5 -0
- package/dist/lib/config/service-token.d.ts.map +1 -0
- package/dist/lib/config/service-token.js +18 -0
- package/dist/lib/config/service-token.js.map +1 -0
- package/dist/lib/crud/entity-crud-form.d.ts +49 -0
- package/dist/lib/crud/entity-crud-form.d.ts.map +1 -0
- package/dist/lib/crud/entity-crud-form.js +133 -0
- package/dist/lib/crud/entity-crud-form.js.map +1 -0
- package/dist/lib/crud/hand-off-write.d.ts +35 -0
- package/dist/lib/crud/hand-off-write.d.ts.map +1 -0
- package/dist/lib/crud/hand-off-write.js +27 -0
- package/dist/lib/crud/hand-off-write.js.map +1 -0
- package/dist/lib/crud/index.d.ts +9 -0
- package/dist/lib/crud/index.d.ts.map +1 -0
- package/dist/lib/crud/index.js +10 -0
- package/dist/lib/crud/index.js.map +1 -0
- package/dist/lib/crud/make-entity-crud.d.ts +28 -0
- package/dist/lib/crud/make-entity-crud.d.ts.map +1 -0
- package/dist/lib/crud/make-entity-crud.js +318 -0
- package/dist/lib/crud/make-entity-crud.js.map +1 -0
- package/dist/lib/crud/make-entity-crud.types.d.ts +237 -0
- package/dist/lib/crud/make-entity-crud.types.d.ts.map +1 -0
- package/dist/lib/crud/make-entity-crud.types.js +6 -0
- package/dist/lib/crud/make-entity-crud.types.js.map +1 -0
- package/dist/lib/crud/pending-notice.d.ts +31 -0
- package/dist/lib/crud/pending-notice.d.ts.map +1 -0
- package/dist/lib/crud/pending-notice.js +72 -0
- package/dist/lib/crud/pending-notice.js.map +1 -0
- package/dist/lib/crud/slug.d.ts +13 -0
- package/dist/lib/crud/slug.d.ts.map +1 -0
- package/dist/lib/crud/slug.js +13 -0
- package/dist/lib/crud/slug.js.map +1 -0
- package/dist/lib/crud/use-entity-affordances.d.ts +20 -0
- package/dist/lib/crud/use-entity-affordances.d.ts.map +1 -0
- package/dist/lib/crud/use-entity-affordances.js +22 -0
- package/dist/lib/crud/use-entity-affordances.js.map +1 -0
- package/dist/lib/crud/use-entity-bulk.d.ts +43 -0
- package/dist/lib/crud/use-entity-bulk.d.ts.map +1 -0
- package/dist/lib/crud/use-entity-bulk.js +106 -0
- package/dist/lib/crud/use-entity-bulk.js.map +1 -0
- package/dist/lib/crud/use-entity-link-sources.d.ts +25 -0
- package/dist/lib/crud/use-entity-link-sources.d.ts.map +1 -0
- package/dist/lib/crud/use-entity-link-sources.js +37 -0
- package/dist/lib/crud/use-entity-link-sources.js.map +1 -0
- package/dist/lib/health/health-routes.d.ts +37 -0
- package/dist/lib/health/health-routes.d.ts.map +1 -0
- package/dist/lib/health/health-routes.js +90 -0
- package/dist/lib/health/health-routes.js.map +1 -0
- package/dist/lib/i18n/catalog/en.d.ts +4 -0
- package/dist/lib/i18n/catalog/en.d.ts.map +1 -0
- package/dist/lib/i18n/catalog/en.js +347 -0
- package/dist/lib/i18n/catalog/en.js.map +1 -0
- package/dist/lib/i18n/catalog/es.d.ts +397 -0
- package/dist/lib/i18n/catalog/es.d.ts.map +1 -0
- package/dist/lib/i18n/catalog/es.js +407 -0
- package/dist/lib/i18n/catalog/es.js.map +1 -0
- package/dist/lib/i18n/catalog/index.d.ts +671 -0
- package/dist/lib/i18n/catalog/index.d.ts.map +1 -0
- package/dist/lib/i18n/catalog/index.js +28 -0
- package/dist/lib/i18n/catalog/index.js.map +1 -0
- package/dist/lib/i18n/index.d.ts +2 -0
- package/dist/lib/i18n/index.d.ts.map +1 -0
- package/dist/lib/i18n/index.js +3 -0
- package/dist/lib/i18n/index.js.map +1 -0
- package/dist/lib/i18n/locale-link.d.ts +23 -0
- package/dist/lib/i18n/locale-link.d.ts.map +1 -0
- package/dist/lib/i18n/locale-link.js +43 -0
- package/dist/lib/i18n/locale-link.js.map +1 -0
- package/dist/lib/i18n/spec-catalog.d.ts +2 -0
- package/dist/lib/i18n/spec-catalog.d.ts.map +1 -0
- package/dist/lib/i18n/spec-catalog.js +84 -0
- package/dist/lib/i18n/spec-catalog.js.map +1 -0
- package/dist/lib/search/record-search-route.d.ts +44 -0
- package/dist/lib/search/record-search-route.d.ts.map +1 -0
- package/dist/lib/search/record-search-route.js +158 -0
- package/dist/lib/search/record-search-route.js.map +1 -0
- package/dist/lib/search/record-search-source.d.ts +59 -0
- package/dist/lib/search/record-search-source.d.ts.map +1 -0
- package/dist/lib/search/record-search-source.js +118 -0
- package/dist/lib/search/record-search-source.js.map +1 -0
- package/dist/lib/search/record-search.types.d.ts +85 -0
- package/dist/lib/search/record-search.types.d.ts.map +1 -0
- package/dist/lib/search/record-search.types.js +29 -0
- package/dist/lib/search/record-search.types.js.map +1 -0
- package/dist/lib/search/search-records.d.ts +25 -0
- package/dist/lib/search/search-records.d.ts.map +1 -0
- package/dist/lib/search/search-records.js +36 -0
- package/dist/lib/search/search-records.js.map +1 -0
- package/dist/lib/session/account-links.d.ts +46 -0
- package/dist/lib/session/account-links.d.ts.map +1 -0
- package/dist/lib/session/account-links.js +41 -0
- package/dist/lib/session/account-links.js.map +1 -0
- package/dist/lib/session/bearer.d.ts +25 -0
- package/dist/lib/session/bearer.d.ts.map +1 -0
- package/dist/lib/session/bearer.js +31 -0
- package/dist/lib/session/bearer.js.map +1 -0
- package/dist/lib/session/cookies.d.ts +14 -0
- package/dist/lib/session/cookies.d.ts.map +1 -0
- package/dist/lib/session/cookies.js +33 -0
- package/dist/lib/session/cookies.js.map +1 -0
- package/dist/lib/session/device.d.ts +31 -0
- package/dist/lib/session/device.d.ts.map +1 -0
- package/dist/lib/session/device.js +80 -0
- package/dist/lib/session/device.js.map +1 -0
- package/dist/lib/session/index.d.ts +3 -0
- package/dist/lib/session/index.d.ts.map +1 -0
- package/dist/lib/session/index.js +12 -0
- package/dist/lib/session/index.js.map +1 -0
- package/dist/lib/session/refresh-route.d.ts +19 -0
- package/dist/lib/session/refresh-route.d.ts.map +1 -0
- package/dist/lib/session/refresh-route.js +85 -0
- package/dist/lib/session/refresh-route.js.map +1 -0
- package/dist/lib/session/session-keepalive.d.ts +52 -0
- package/dist/lib/session/session-keepalive.d.ts.map +1 -0
- package/dist/lib/session/session-keepalive.js +72 -0
- package/dist/lib/session/session-keepalive.js.map +1 -0
- package/dist/lib/session/use-session-refresh.d.ts +43 -0
- package/dist/lib/session/use-session-refresh.d.ts.map +1 -0
- package/dist/lib/session/use-session-refresh.js +120 -0
- package/dist/lib/session/use-session-refresh.js.map +1 -0
- package/dist/lib/wizard/index.d.ts +4 -0
- package/dist/lib/wizard/index.d.ts.map +1 -0
- package/dist/lib/wizard/index.js +5 -0
- package/dist/lib/wizard/index.js.map +1 -0
- package/dist/lib/wizard/use-wizard-draft.d.ts +22 -0
- package/dist/lib/wizard/use-wizard-draft.d.ts.map +1 -0
- package/dist/lib/wizard/use-wizard-draft.js +39 -0
- package/dist/lib/wizard/use-wizard-draft.js.map +1 -0
- package/dist/lib/wizard/use-wizard-step-param.d.ts +60 -0
- package/dist/lib/wizard/use-wizard-step-param.d.ts.map +1 -0
- package/dist/lib/wizard/use-wizard-step-param.js +94 -0
- package/dist/lib/wizard/use-wizard-step-param.js.map +1 -0
- package/dist/lib/wizard/wizard-tab-kind.d.ts +31 -0
- package/dist/lib/wizard/wizard-tab-kind.d.ts.map +1 -0
- package/dist/lib/wizard/wizard-tab-kind.js +33 -0
- package/dist/lib/wizard/wizard-tab-kind.js.map +1 -0
- package/dist/lib/workspace/drafts-state.d.ts +88 -0
- package/dist/lib/workspace/drafts-state.d.ts.map +1 -0
- package/dist/lib/workspace/drafts-state.js +128 -0
- package/dist/lib/workspace/drafts-state.js.map +1 -0
- package/dist/lib/workspace/entity-nav.d.ts +26 -0
- package/dist/lib/workspace/entity-nav.d.ts.map +1 -0
- package/dist/lib/workspace/entity-nav.js +56 -0
- package/dist/lib/workspace/entity-nav.js.map +1 -0
- package/dist/lib/workspace/entity-tab-kind.d.ts +51 -0
- package/dist/lib/workspace/entity-tab-kind.d.ts.map +1 -0
- package/dist/lib/workspace/entity-tab-kind.js +47 -0
- package/dist/lib/workspace/entity-tab-kind.js.map +1 -0
- package/dist/lib/workspace/idb-state-storage.d.ts +9 -0
- package/dist/lib/workspace/idb-state-storage.d.ts.map +1 -0
- package/dist/lib/workspace/idb-state-storage.js +32 -0
- package/dist/lib/workspace/idb-state-storage.js.map +1 -0
- package/dist/lib/workspace/index.d.ts +13 -0
- package/dist/lib/workspace/index.d.ts.map +1 -0
- package/dist/lib/workspace/index.js +14 -0
- package/dist/lib/workspace/index.js.map +1 -0
- package/dist/lib/workspace/pending-state.d.ts +84 -0
- package/dist/lib/workspace/pending-state.d.ts.map +1 -0
- package/dist/lib/workspace/pending-state.js +155 -0
- package/dist/lib/workspace/pending-state.js.map +1 -0
- package/dist/lib/workspace/pending-transactions.d.ts +44 -0
- package/dist/lib/workspace/pending-transactions.d.ts.map +1 -0
- package/dist/lib/workspace/pending-transactions.js +114 -0
- package/dist/lib/workspace/pending-transactions.js.map +1 -0
- package/dist/lib/workspace/tab-kind.d.ts +48 -0
- package/dist/lib/workspace/tab-kind.d.ts.map +1 -0
- package/dist/lib/workspace/tab-kind.js +47 -0
- package/dist/lib/workspace/tab-kind.js.map +1 -0
- package/dist/lib/workspace/tab-state.d.ts +36 -0
- package/dist/lib/workspace/tab-state.d.ts.map +1 -0
- package/dist/lib/workspace/tab-state.js +81 -0
- package/dist/lib/workspace/tab-state.js.map +1 -0
- package/dist/lib/workspace/tabs-state.d.ts +46 -0
- package/dist/lib/workspace/tabs-state.d.ts.map +1 -0
- package/dist/lib/workspace/tabs-state.js +56 -0
- package/dist/lib/workspace/tabs-state.js.map +1 -0
- package/dist/lib/workspace/use-entity-draft.d.ts +25 -0
- package/dist/lib/workspace/use-entity-draft.d.ts.map +1 -0
- package/dist/lib/workspace/use-entity-draft.js +38 -0
- package/dist/lib/workspace/use-entity-draft.js.map +1 -0
- package/dist/lib/workspace/workspace-scope.d.ts +51 -0
- package/dist/lib/workspace/workspace-scope.d.ts.map +1 -0
- package/dist/lib/workspace/workspace-scope.js +42 -0
- package/dist/lib/workspace/workspace-scope.js.map +1 -0
- package/dist/lib/workspace/workspace-shell.d.ts +33 -0
- package/dist/lib/workspace/workspace-shell.d.ts.map +1 -0
- package/dist/lib/workspace/workspace-shell.js +206 -0
- package/dist/lib/workspace/workspace-shell.js.map +1 -0
- package/dist/server.d.ts +13 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +41 -0
- package/dist/server.js.map +1 -0
- package/package.json +95 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/lib/i18n/index.ts"],"names":[],"mappings":"AAAA,cAAc,kBAAkB,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/lib/i18n/index.ts"],"sourcesContent":["export * from './locale-link.js';\n"],"names":[],"mappings":"AAAA,cAAc,mBAAmB"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type Locale } from '@entifix/core';
|
|
2
|
+
import Link from 'next/link';
|
|
3
|
+
import type { ComponentPropsWithoutRef } from 'react';
|
|
4
|
+
/**
|
|
5
|
+
* Prefixes an in-app href with the active locale.
|
|
6
|
+
*
|
|
7
|
+
* Every internal link has to go through this. An unprefixed `/catalog` still
|
|
8
|
+
* works — the middleware negotiates and redirects — but the visitor pays a
|
|
9
|
+
* round trip and, worse, a visitor reading `/en/...` who follows a bare link
|
|
10
|
+
* lands back on whatever their cookie says. The helper is idempotent and leaves
|
|
11
|
+
* absolute URLs alone, so it is safe to apply at every call site.
|
|
12
|
+
*/
|
|
13
|
+
export declare function useLocaleHref(): (href: string) => string;
|
|
14
|
+
/**
|
|
15
|
+
* The locale the URL is actually on. Reads the browser path rather than the
|
|
16
|
+
* provider, so it is correct inside `usePathname`-driven code (breadcrumbs)
|
|
17
|
+
* even before the provider re-renders.
|
|
18
|
+
*/
|
|
19
|
+
export declare function usePathLocale(): Locale | undefined;
|
|
20
|
+
export type LocaleLinkProps = ComponentPropsWithoutRef<typeof Link>;
|
|
21
|
+
/** `next/link` with the active locale already on the href. */
|
|
22
|
+
export declare function LocaleLink({ href, ...props }: LocaleLinkProps): import("react").JSX.Element;
|
|
23
|
+
//# sourceMappingURL=locale-link.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"locale-link.d.ts","sourceRoot":"","sources":["../../../src/lib/i18n/locale-link.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAE,KAAK,MAAM,EAA+B,MAAM,eAAe,CAAC;AAEzE,OAAO,IAAI,MAAM,WAAW,CAAC;AAE7B,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,OAAO,CAAC;AAGtD;;;;;;;;GAQG;AACH,wBAAgB,aAAa,IAAI,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAGxD;AAED;;;;GAIG;AACH,wBAAgB,aAAa,IAAI,MAAM,GAAG,SAAS,CAElD;AAED,MAAM,MAAM,eAAe,GAAG,wBAAwB,CAAC,OAAO,IAAI,CAAC,CAAC;AAEpE,8DAA8D;AAC9D,wBAAgB,UAAU,CAAC,EAAE,IAAI,EAAE,GAAG,KAAK,EAAE,EAAE,eAAe,+BAK7D"}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
3
|
+
import { _ as _object_without_properties_loose } from "@swc/helpers/_/_object_without_properties_loose";
|
|
4
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
5
|
+
import { localeHref, splitLocalePath } from '@entifix/core';
|
|
6
|
+
import { useLocale } from '@entifix/react-controls';
|
|
7
|
+
import Link from 'next/link';
|
|
8
|
+
import { usePathname } from 'next/navigation';
|
|
9
|
+
import { useCallback } from 'react';
|
|
10
|
+
/**
|
|
11
|
+
* Prefixes an in-app href with the active locale.
|
|
12
|
+
*
|
|
13
|
+
* Every internal link has to go through this. An unprefixed `/catalog` still
|
|
14
|
+
* works — the middleware negotiates and redirects — but the visitor pays a
|
|
15
|
+
* round trip and, worse, a visitor reading `/en/...` who follows a bare link
|
|
16
|
+
* lands back on whatever their cookie says. The helper is idempotent and leaves
|
|
17
|
+
* absolute URLs alone, so it is safe to apply at every call site.
|
|
18
|
+
*/ export function useLocaleHref() {
|
|
19
|
+
const locale = useLocale();
|
|
20
|
+
return useCallback((href)=>localeHref(locale, href), [
|
|
21
|
+
locale
|
|
22
|
+
]);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The locale the URL is actually on. Reads the browser path rather than the
|
|
26
|
+
* provider, so it is correct inside `usePathname`-driven code (breadcrumbs)
|
|
27
|
+
* even before the provider re-renders.
|
|
28
|
+
*/ export function usePathLocale() {
|
|
29
|
+
var _usePathname;
|
|
30
|
+
return splitLocalePath((_usePathname = usePathname()) != null ? _usePathname : '/').locale;
|
|
31
|
+
}
|
|
32
|
+
/** `next/link` with the active locale already on the href. */ export function LocaleLink(_0) {
|
|
33
|
+
let { href } = _0, props = _object_without_properties_loose(_0, [
|
|
34
|
+
"href"
|
|
35
|
+
]);
|
|
36
|
+
const withLocale = useLocaleHref();
|
|
37
|
+
const resolved = typeof href === 'string' ? withLocale(href) : href;
|
|
38
|
+
return /*#__PURE__*/ _jsx(Link, _extends({
|
|
39
|
+
href: resolved
|
|
40
|
+
}, props));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
//# sourceMappingURL=locale-link.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/lib/i18n/locale-link.tsx"],"sourcesContent":["'use client';\n\nimport { type Locale, localeHref, splitLocalePath } from '@entifix/core';\nimport { useLocale } from '@entifix/react-controls';\nimport Link from 'next/link';\nimport { usePathname } from 'next/navigation';\nimport type { ComponentPropsWithoutRef } from 'react';\nimport { useCallback } from 'react';\n\n/**\n * Prefixes an in-app href with the active locale.\n *\n * Every internal link has to go through this. An unprefixed `/catalog` still\n * works — the middleware negotiates and redirects — but the visitor pays a\n * round trip and, worse, a visitor reading `/en/...` who follows a bare link\n * lands back on whatever their cookie says. The helper is idempotent and leaves\n * absolute URLs alone, so it is safe to apply at every call site.\n */\nexport function useLocaleHref(): (href: string) => string {\n const locale = useLocale();\n return useCallback((href: string) => localeHref(locale, href), [locale]);\n}\n\n/**\n * The locale the URL is actually on. Reads the browser path rather than the\n * provider, so it is correct inside `usePathname`-driven code (breadcrumbs)\n * even before the provider re-renders.\n */\nexport function usePathLocale(): Locale | undefined {\n return splitLocalePath(usePathname() ?? '/').locale;\n}\n\nexport type LocaleLinkProps = ComponentPropsWithoutRef<typeof Link>;\n\n/** `next/link` with the active locale already on the href. */\nexport function LocaleLink({ href, ...props }: LocaleLinkProps) {\n const withLocale = useLocaleHref();\n const resolved = typeof href === 'string' ? withLocale(href) : href;\n\n return <Link href={resolved} {...props} />;\n}\n"],"names":["localeHref","splitLocalePath","useLocale","Link","usePathname","useCallback","useLocaleHref","locale","href","usePathLocale","LocaleLink","props","withLocale","resolved"],"mappings":"AAAA;;;;AAEA,SAAsBA,UAAU,EAAEC,eAAe,QAAQ,gBAAgB;AACzE,SAASC,SAAS,QAAQ,0BAA0B;AACpD,OAAOC,UAAU,YAAY;AAC7B,SAASC,WAAW,QAAQ,kBAAkB;AAE9C,SAASC,WAAW,QAAQ,QAAQ;AAEpC;;;;;;;;CAQC,GACD,OAAO,SAASC;IACd,MAAMC,SAASL;IACf,OAAOG,YAAY,CAACG,OAAiBR,WAAWO,QAAQC,OAAO;QAACD;KAAO;AACzE;AAEA;;;;CAIC,GACD,OAAO,SAASE;QACSL;IAAvB,OAAOH,iBAAgBG,eAAAA,yBAAAA,eAAiB,KAAKG,MAAM;AACrD;AAIA,4DAA4D,GAC5D,OAAO,SAASG,WAAW,EAAmC;QAAnC,EAAEF,IAAI,EAA6B,OAAxBG;;;IACpC,MAAMC,aAAaN;IACnB,MAAMO,WAAW,OAAOL,SAAS,WAAWI,WAAWJ,QAAQA;IAE/D,qBAAO,KAACL;QAAKK,MAAMK;OAAcF;AACnC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"spec-catalog.d.ts","sourceRoot":"","sources":["../../../src/lib/i18n/spec-catalog.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { registerFallbackCatalog } from '@entifix/react-controls';
|
|
2
|
+
/**
|
|
3
|
+
* An `entity` catalog for this package's own specs.
|
|
4
|
+
*
|
|
5
|
+
* ⚠️ **A fixture, not a shipped catalog.** `entity` names the *host's* entities,
|
|
6
|
+
* so a framework shipping one would be shipping product copy — which is what
|
|
7
|
+
* [ADR 0059](../../../../../../docs/adr/0059-entifix-leaves-the-repo.md)
|
|
8
|
+
* removes. These specs assert that a nav entry, a palette command and a
|
|
9
|
+
* workspace tab name a record in the catalog's own words; a fixture proves that
|
|
10
|
+
* as well as r10c's copy did, and keeps the assertions readable as sentences.
|
|
11
|
+
*/ const es = {
|
|
12
|
+
product: {
|
|
13
|
+
label: 'Producto',
|
|
14
|
+
plural: 'Productos'
|
|
15
|
+
},
|
|
16
|
+
thing: {
|
|
17
|
+
label: 'Cosa',
|
|
18
|
+
plural: 'Cosas'
|
|
19
|
+
},
|
|
20
|
+
widget: {
|
|
21
|
+
label: 'Artilugio',
|
|
22
|
+
plural: 'Artilugios'
|
|
23
|
+
},
|
|
24
|
+
'product-category': {
|
|
25
|
+
label: 'Categoría',
|
|
26
|
+
plural: 'Categorías'
|
|
27
|
+
},
|
|
28
|
+
'product-brand': {
|
|
29
|
+
label: 'Marca',
|
|
30
|
+
plural: 'Marcas',
|
|
31
|
+
form: {
|
|
32
|
+
newTitle: 'Nueva marca',
|
|
33
|
+
editTitle: 'Editar marca'
|
|
34
|
+
},
|
|
35
|
+
fields: {
|
|
36
|
+
id: 'Id',
|
|
37
|
+
code: 'Código',
|
|
38
|
+
name: 'Nombre',
|
|
39
|
+
website: 'Sitio web'
|
|
40
|
+
},
|
|
41
|
+
// No `archive` on purpose — a CRUD spec asserts that a verb the
|
|
42
|
+
// catalog does not name still renders, as its key.
|
|
43
|
+
useCases: {
|
|
44
|
+
retire: 'Retirar'
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
'product-specification': {
|
|
48
|
+
label: 'Producto',
|
|
49
|
+
plural: 'Productos',
|
|
50
|
+
fields: {
|
|
51
|
+
id: 'Id',
|
|
52
|
+
name: 'Nombre',
|
|
53
|
+
brand: 'Marca'
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
'user-identity': {
|
|
57
|
+
label: 'Usuario',
|
|
58
|
+
plural: 'Usuarios',
|
|
59
|
+
useCases: {
|
|
60
|
+
signOutOthers: 'Cerrar mis otras sesiones',
|
|
61
|
+
signOutOthersConfirm: 'Se cerrarán todas tus sesiones en otros dispositivos y navegadores. ' + 'Ésta seguirá abierta.',
|
|
62
|
+
signOutOthersKeywords: 'cerrar sesión, dispositivos, logout'
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
registerFallbackCatalog('entity', {
|
|
67
|
+
es,
|
|
68
|
+
en: es
|
|
69
|
+
});
|
|
70
|
+
/**
|
|
71
|
+
* The same for `errors` — the code vocabulary a host's services answer with.
|
|
72
|
+
* The palette renders a scope refusal through it, so it needs the two codes
|
|
73
|
+
* these specs actually raise.
|
|
74
|
+
*/ const errors = {
|
|
75
|
+
noActiveOrganization: 'Selecciona una organización para ver estos registros.',
|
|
76
|
+
alreadyRetired: 'El registro ya estaba retirado.',
|
|
77
|
+
unexpected: 'Ocurrió un error inesperado.'
|
|
78
|
+
};
|
|
79
|
+
registerFallbackCatalog('errors', {
|
|
80
|
+
es: errors,
|
|
81
|
+
en: errors
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
//# sourceMappingURL=spec-catalog.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/lib/i18n/spec-catalog.ts"],"sourcesContent":["import { registerFallbackCatalog } from '@entifix/react-controls';\n\n/**\n * An `entity` catalog for this package's own specs.\n *\n * ⚠️ **A fixture, not a shipped catalog.** `entity` names the *host's* entities,\n * so a framework shipping one would be shipping product copy — which is what\n * [ADR 0059](../../../../../../docs/adr/0059-entifix-leaves-the-repo.md)\n * removes. These specs assert that a nav entry, a palette command and a\n * workspace tab name a record in the catalog's own words; a fixture proves that\n * as well as r10c's copy did, and keeps the assertions readable as sentences.\n */\nconst es = {\n product: { label: 'Producto', plural: 'Productos' },\n thing: { label: 'Cosa', plural: 'Cosas' },\n widget: { label: 'Artilugio', plural: 'Artilugios' },\n 'product-category': { label: 'Categoría', plural: 'Categorías' },\n 'product-brand': {\n label: 'Marca',\n plural: 'Marcas',\n form: { newTitle: 'Nueva marca', editTitle: 'Editar marca' },\n fields: {\n id: 'Id',\n code: 'Código',\n name: 'Nombre',\n website: 'Sitio web',\n },\n // No `archive` on purpose — a CRUD spec asserts that a verb the\n // catalog does not name still renders, as its key.\n useCases: { retire: 'Retirar' },\n },\n 'product-specification': {\n label: 'Producto',\n plural: 'Productos',\n fields: { id: 'Id', name: 'Nombre', brand: 'Marca' },\n },\n 'user-identity': {\n label: 'Usuario',\n plural: 'Usuarios',\n useCases: {\n signOutOthers: 'Cerrar mis otras sesiones',\n signOutOthersConfirm:\n 'Se cerrarán todas tus sesiones en otros dispositivos y navegadores. ' +\n 'Ésta seguirá abierta.',\n signOutOthersKeywords: 'cerrar sesión, dispositivos, logout',\n },\n },\n} as const;\n\nregisterFallbackCatalog('entity', { es, en: es });\n\n/**\n * The same for `errors` — the code vocabulary a host's services answer with.\n * The palette renders a scope refusal through it, so it needs the two codes\n * these specs actually raise.\n */\nconst errors = {\n noActiveOrganization: 'Selecciona una organización para ver estos registros.',\n alreadyRetired: 'El registro ya estaba retirado.',\n unexpected: 'Ocurrió un error inesperado.',\n} as const;\n\nregisterFallbackCatalog('errors', { es: errors, en: errors });\n"],"names":["registerFallbackCatalog","es","product","label","plural","thing","widget","form","newTitle","editTitle","fields","id","code","name","website","useCases","retire","brand","signOutOthers","signOutOthersConfirm","signOutOthersKeywords","en","errors","noActiveOrganization","alreadyRetired","unexpected"],"mappings":"AAAA,SAASA,uBAAuB,QAAQ,0BAA0B;AAElE;;;;;;;;;CASC,GACD,MAAMC,KAAK;IACTC,SAAS;QAAEC,OAAO;QAAYC,QAAQ;IAAY;IAClDC,OAAO;QAAEF,OAAO;QAAQC,QAAQ;IAAQ;IACxCE,QAAQ;QAAEH,OAAO;QAAaC,QAAQ;IAAa;IACnD,oBAAoB;QAAED,OAAO;QAAaC,QAAQ;IAAa;IAC/D,iBAAiB;QACfD,OAAO;QACPC,QAAQ;QACRG,MAAM;YAAEC,UAAU;YAAeC,WAAW;QAAe;QAC3DC,QAAQ;YACNC,IAAI;YACJC,MAAM;YACNC,MAAM;YACNC,SAAS;QACX;QACA,gEAAgE;QAChE,mDAAmD;QACnDC,UAAU;YAAEC,QAAQ;QAAU;IAChC;IACA,yBAAyB;QACvBb,OAAO;QACPC,QAAQ;QACRM,QAAQ;YAAEC,IAAI;YAAME,MAAM;YAAUI,OAAO;QAAQ;IACrD;IACA,iBAAiB;QACfd,OAAO;QACPC,QAAQ;QACRW,UAAU;YACRG,eAAe;YACfC,sBACE,yEACA;YACFC,uBAAuB;QACzB;IACF;AACF;AAEApB,wBAAwB,UAAU;IAAEC;IAAIoB,IAAIpB;AAAG;AAE/C;;;;CAIC,GACD,MAAMqB,SAAS;IACbC,sBAAsB;IACtBC,gBAAgB;IAChBC,YAAY;AACd;AAEAzB,wBAAwB,UAAU;IAAEC,IAAIqB;IAAQD,IAAIC;AAAO"}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { RecordSearchSource } from './record-search-source.js';
|
|
2
|
+
export declare const SEARCH_TERM_PARAM = "q";
|
|
3
|
+
export declare const SEARCH_SOURCES_PARAM = "sources";
|
|
4
|
+
export declare const SEARCH_LIMIT_PARAM = "limit";
|
|
5
|
+
/**
|
|
6
|
+
* Below this, nothing is queried at all.
|
|
7
|
+
*
|
|
8
|
+
* A `like` becomes an unanchored, case-insensitive `$regex`, which no index can
|
|
9
|
+
* serve — so this endpoint is a collection scan by construction. One character
|
|
10
|
+
* matches most of every collection and is worth nothing to the person typing, so
|
|
11
|
+
* the floor costs them nothing and saves four scans per keystroke.
|
|
12
|
+
*/
|
|
13
|
+
export declare const MIN_TERM_LENGTH = 2;
|
|
14
|
+
export declare const DEFAULT_GROUP_LIMIT = 5;
|
|
15
|
+
export declare const MAX_GROUP_LIMIT = 20;
|
|
16
|
+
export declare const DEFAULT_SOURCE_TIMEOUT_MS = 1500;
|
|
17
|
+
export interface RecordSearchRouteOptions {
|
|
18
|
+
/**
|
|
19
|
+
* The sources to fan out over, **in the order results are presented**. That
|
|
20
|
+
* order is the ranking: an operator learns where a kind of record lands, which
|
|
21
|
+
* a relevance score across four unrelated entities cannot offer.
|
|
22
|
+
*/
|
|
23
|
+
readonly sources: readonly RecordSearchSource[];
|
|
24
|
+
/** Per-source budget. Concurrent, so this also bounds the whole request. */
|
|
25
|
+
readonly timeoutMs?: number;
|
|
26
|
+
/** Records per group. */
|
|
27
|
+
readonly limit?: number;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* `GET /api/search` — records matching a term, across every source this host
|
|
31
|
+
* mounts (ADR 0040).
|
|
32
|
+
*
|
|
33
|
+
* The caller's own session is the only credential that crosses: each source is
|
|
34
|
+
* asked over the same guarded route its screens use, so what comes back is
|
|
35
|
+
* already authorized for this principal and this organization. There is no
|
|
36
|
+
* index, and there must never be one — a prefetched index is a tenant leak, and
|
|
37
|
+
* it is the reason this is a fan-out rather than a search service.
|
|
38
|
+
*
|
|
39
|
+
* A source that fails is **named** in `unavailable` rather than dropped. An
|
|
40
|
+
* absent group renders as "no matches", which is a confident claim the server is
|
|
41
|
+
* in no position to make about a service it could not reach.
|
|
42
|
+
*/
|
|
43
|
+
export declare const createRecordSearchRoute: ({ sources, timeoutMs, limit: defaultLimit, }: RecordSearchRouteOptions) => (request: Request) => Promise<Response>;
|
|
44
|
+
//# sourceMappingURL=record-search-route.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"record-search-route.d.ts","sourceRoot":"","sources":["../../../src/lib/search/record-search-route.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAEpE,eAAO,MAAM,iBAAiB,MAAM,CAAC;AACrC,eAAO,MAAM,oBAAoB,YAAY,CAAC;AAC9C,eAAO,MAAM,kBAAkB,UAAU,CAAC;AAE1C;;;;;;;GAOG;AACH,eAAO,MAAM,eAAe,IAAI,CAAC;AACjC,eAAO,MAAM,mBAAmB,IAAI,CAAC;AACrC,eAAO,MAAM,eAAe,KAAK,CAAC;AAClC,eAAO,MAAM,yBAAyB,OAAQ,CAAC;AAa/C,MAAM,WAAW,wBAAwB;IACvC;;;;OAIG;IACH,QAAQ,CAAC,OAAO,EAAE,SAAS,kBAAkB,EAAE,CAAC;IAChD,4EAA4E;IAC5E,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,yBAAyB;IACzB,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;CACzB;AAmFD;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,uBAAuB,GAAI,8CAIrC,wBAAwB,MAGC,SAAS,OAAO,KAAG,OAAO,CAAC,QAAQ,CA+D9D,CAAC"}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
import { NextResponse } from 'next/server';
|
|
3
|
+
import { bearerHeader, sessionToken } from '../session/bearer.js';
|
|
4
|
+
export const SEARCH_TERM_PARAM = 'q';
|
|
5
|
+
export const SEARCH_SOURCES_PARAM = 'sources';
|
|
6
|
+
export const SEARCH_LIMIT_PARAM = 'limit';
|
|
7
|
+
/**
|
|
8
|
+
* Below this, nothing is queried at all.
|
|
9
|
+
*
|
|
10
|
+
* A `like` becomes an unanchored, case-insensitive `$regex`, which no index can
|
|
11
|
+
* serve — so this endpoint is a collection scan by construction. One character
|
|
12
|
+
* matches most of every collection and is worth nothing to the person typing, so
|
|
13
|
+
* the floor costs them nothing and saves four scans per keystroke.
|
|
14
|
+
*/ export const MIN_TERM_LENGTH = 2;
|
|
15
|
+
export const DEFAULT_GROUP_LIMIT = 5;
|
|
16
|
+
export const MAX_GROUP_LIMIT = 20;
|
|
17
|
+
export const DEFAULT_SOURCE_TIMEOUT_MS = 1500;
|
|
18
|
+
/** Upstream statuses that mean something other than "we could not reach it". */ const REASON_FOR_STATUS = {
|
|
19
|
+
400: 'invalidQuery',
|
|
20
|
+
401: 'unauthenticated',
|
|
21
|
+
403: 'forbidden',
|
|
22
|
+
404: 'notFound',
|
|
23
|
+
409: 'noActiveOrganization'
|
|
24
|
+
};
|
|
25
|
+
const unavailable = (source, reason, status)=>({
|
|
26
|
+
ok: false,
|
|
27
|
+
unavailable: _extends({
|
|
28
|
+
source: source.key,
|
|
29
|
+
entity: source.entity,
|
|
30
|
+
reason
|
|
31
|
+
}, status === undefined ? {} : {
|
|
32
|
+
status
|
|
33
|
+
})
|
|
34
|
+
});
|
|
35
|
+
/**
|
|
36
|
+
* Ask one source, and **never reject**.
|
|
37
|
+
*
|
|
38
|
+
* Returning a typed outcome rather than throwing is what makes the fan-out a
|
|
39
|
+
* plain `Promise.all`: isolation comes from this catch, not from
|
|
40
|
+
* `allSettled` — whose rejected arm would then be unreachable and untestable.
|
|
41
|
+
*/ const runSource = async (source, term, limit, token, timeoutMs)=>{
|
|
42
|
+
let upstream;
|
|
43
|
+
try {
|
|
44
|
+
upstream = await fetch(source.url(term, limit), {
|
|
45
|
+
headers: bearerHeader(token),
|
|
46
|
+
cache: 'no-store',
|
|
47
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
48
|
+
});
|
|
49
|
+
} catch (error) {
|
|
50
|
+
// `AbortSignal.timeout` rejects with a `DOMException` named `TimeoutError`;
|
|
51
|
+
// branching on the name rather than the class keeps this reachable from a
|
|
52
|
+
// test without fabricating a platform error.
|
|
53
|
+
return unavailable(source, error.name === 'TimeoutError' ? 'timeout' : 'network');
|
|
54
|
+
}
|
|
55
|
+
if (!upstream.ok) {
|
|
56
|
+
var _REASON_FOR_STATUS_upstream_status;
|
|
57
|
+
return unavailable(source, (_REASON_FOR_STATUS_upstream_status = REASON_FOR_STATUS[upstream.status]) != null ? _REASON_FOR_STATUS_upstream_status : 'unexpected', upstream.status);
|
|
58
|
+
}
|
|
59
|
+
const body = await upstream.json().catch(()=>undefined);
|
|
60
|
+
const page = source.read(body);
|
|
61
|
+
return page === undefined ? unavailable(source, 'unexpected', upstream.status) : {
|
|
62
|
+
ok: true,
|
|
63
|
+
group: {
|
|
64
|
+
source: source.key,
|
|
65
|
+
entity: source.entity,
|
|
66
|
+
labelKey: source.labelKey,
|
|
67
|
+
items: page.items,
|
|
68
|
+
total: page.total
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
const clampLimit = (raw, fallback)=>{
|
|
73
|
+
const asked = Number(raw);
|
|
74
|
+
if (!Number.isInteger(asked) || asked < 1) return fallback;
|
|
75
|
+
return Math.min(asked, MAX_GROUP_LIMIT);
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* `GET /api/search` — records matching a term, across every source this host
|
|
79
|
+
* mounts (ADR 0040).
|
|
80
|
+
*
|
|
81
|
+
* The caller's own session is the only credential that crosses: each source is
|
|
82
|
+
* asked over the same guarded route its screens use, so what comes back is
|
|
83
|
+
* already authorized for this principal and this organization. There is no
|
|
84
|
+
* index, and there must never be one — a prefetched index is a tenant leak, and
|
|
85
|
+
* it is the reason this is a fan-out rather than a search service.
|
|
86
|
+
*
|
|
87
|
+
* A source that fails is **named** in `unavailable` rather than dropped. An
|
|
88
|
+
* absent group renders as "no matches", which is a confident claim the server is
|
|
89
|
+
* in no position to make about a service it could not reach.
|
|
90
|
+
*/ export const createRecordSearchRoute = ({ sources, timeoutMs = DEFAULT_SOURCE_TIMEOUT_MS, limit: defaultLimit = DEFAULT_GROUP_LIMIT })=>{
|
|
91
|
+
const byKey = new Map(sources.map((source)=>[
|
|
92
|
+
source.key,
|
|
93
|
+
source
|
|
94
|
+
]));
|
|
95
|
+
return async function GET(request) {
|
|
96
|
+
var _params_get, _params_get1;
|
|
97
|
+
// The services decide what this caller may read; this only carries the
|
|
98
|
+
// token. But the gate is not redundant: `catalog-reference` reads are
|
|
99
|
+
// unauthenticated at the service by design, so without it a signed-out
|
|
100
|
+
// caller gets brand matches back and the answer reads as a real search —
|
|
101
|
+
// and the endpoint becomes an anonymous amplifier against that service.
|
|
102
|
+
const token = await sessionToken();
|
|
103
|
+
if (token === undefined) {
|
|
104
|
+
return NextResponse.json({
|
|
105
|
+
error: 'unauthenticated',
|
|
106
|
+
code: 'unauthenticated'
|
|
107
|
+
}, {
|
|
108
|
+
status: 401
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
const params = new URL(request.url).searchParams;
|
|
112
|
+
const asked = ((_params_get = params.get(SEARCH_SOURCES_PARAM)) != null ? _params_get : '').split(',').filter((name)=>name !== '');
|
|
113
|
+
const unknown = asked.find((name)=>!byKey.has(name));
|
|
114
|
+
if (unknown !== undefined) {
|
|
115
|
+
// Loud rather than ignored: no keystroke can produce this, only a caller
|
|
116
|
+
// naming a source that does not exist — and silently searching everything
|
|
117
|
+
// instead would answer a question nobody asked.
|
|
118
|
+
return NextResponse.json({
|
|
119
|
+
error: 'invalid query',
|
|
120
|
+
code: 'invalidQuery',
|
|
121
|
+
detail: `unknown search source "${unknown}"`
|
|
122
|
+
}, {
|
|
123
|
+
status: 400
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
const selected = asked.length === 0 ? sources : sources.filter((source)=>asked.includes(source.key));
|
|
127
|
+
const term = ((_params_get1 = params.get(SEARCH_TERM_PARAM)) != null ? _params_get1 : '').trim();
|
|
128
|
+
const answer = (body)=>// The body is scoped to one principal and one organization. `no-store` is
|
|
129
|
+
// correctness here, not tuning.
|
|
130
|
+
NextResponse.json(body, {
|
|
131
|
+
headers: {
|
|
132
|
+
'cache-control': 'no-store'
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
if (term.length < MIN_TERM_LENGTH) {
|
|
136
|
+
return answer({
|
|
137
|
+
term,
|
|
138
|
+
groups: [],
|
|
139
|
+
unavailable: []
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
const limit = clampLimit(params.get(SEARCH_LIMIT_PARAM), defaultLimit);
|
|
143
|
+
const outcomes = await Promise.all(selected.map((source)=>runSource(source, term, limit, token, timeoutMs)));
|
|
144
|
+
return answer({
|
|
145
|
+
term,
|
|
146
|
+
// Declared order, never completion order — otherwise the list reshuffles
|
|
147
|
+
// with the network and nobody can learn where anything lands.
|
|
148
|
+
groups: outcomes.flatMap((outcome)=>outcome.ok ? [
|
|
149
|
+
outcome.group
|
|
150
|
+
] : []),
|
|
151
|
+
unavailable: outcomes.flatMap((outcome)=>outcome.ok ? [] : [
|
|
152
|
+
outcome.unavailable
|
|
153
|
+
])
|
|
154
|
+
});
|
|
155
|
+
};
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
//# sourceMappingURL=record-search-route.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/lib/search/record-search-route.ts"],"sourcesContent":["import { NextResponse } from 'next/server';\n\nimport { bearerHeader, sessionToken } from '../session/bearer.js';\nimport type {\n RecordSearchGroup,\n RecordSearchResponse,\n RecordSearchUnavailable,\n RecordSearchUnavailableReason,\n} from './record-search.types.js';\nimport type { RecordSearchSource } from './record-search-source.js';\n\nexport const SEARCH_TERM_PARAM = 'q';\nexport const SEARCH_SOURCES_PARAM = 'sources';\nexport const SEARCH_LIMIT_PARAM = 'limit';\n\n/**\n * Below this, nothing is queried at all.\n *\n * A `like` becomes an unanchored, case-insensitive `$regex`, which no index can\n * serve — so this endpoint is a collection scan by construction. One character\n * matches most of every collection and is worth nothing to the person typing, so\n * the floor costs them nothing and saves four scans per keystroke.\n */\nexport const MIN_TERM_LENGTH = 2;\nexport const DEFAULT_GROUP_LIMIT = 5;\nexport const MAX_GROUP_LIMIT = 20;\nexport const DEFAULT_SOURCE_TIMEOUT_MS = 1_500;\n\n/** Upstream statuses that mean something other than \"we could not reach it\". */\nconst REASON_FOR_STATUS: Readonly<\n Record<number, RecordSearchUnavailableReason>\n> = {\n 400: 'invalidQuery',\n 401: 'unauthenticated',\n 403: 'forbidden',\n 404: 'notFound',\n 409: 'noActiveOrganization',\n};\n\nexport interface RecordSearchRouteOptions {\n /**\n * The sources to fan out over, **in the order results are presented**. That\n * order is the ranking: an operator learns where a kind of record lands, which\n * a relevance score across four unrelated entities cannot offer.\n */\n readonly sources: readonly RecordSearchSource[];\n /** Per-source budget. Concurrent, so this also bounds the whole request. */\n readonly timeoutMs?: number;\n /** Records per group. */\n readonly limit?: number;\n}\n\ntype SourceOutcome =\n | { readonly ok: true; readonly group: RecordSearchGroup }\n | { readonly ok: false; readonly unavailable: RecordSearchUnavailable };\n\nconst unavailable = (\n source: RecordSearchSource,\n reason: RecordSearchUnavailableReason,\n status?: number,\n): SourceOutcome => ({\n ok: false,\n unavailable: {\n source: source.key,\n entity: source.entity,\n reason,\n ...(status === undefined ? {} : { status }),\n },\n});\n\n/**\n * Ask one source, and **never reject**.\n *\n * Returning a typed outcome rather than throwing is what makes the fan-out a\n * plain `Promise.all`: isolation comes from this catch, not from\n * `allSettled` — whose rejected arm would then be unreachable and untestable.\n */\nconst runSource = async (\n source: RecordSearchSource,\n term: string,\n limit: number,\n token: string | undefined,\n timeoutMs: number,\n): Promise<SourceOutcome> => {\n let upstream: Response;\n try {\n upstream = await fetch(source.url(term, limit), {\n headers: bearerHeader(token),\n cache: 'no-store',\n signal: AbortSignal.timeout(timeoutMs),\n });\n } catch (error) {\n // `AbortSignal.timeout` rejects with a `DOMException` named `TimeoutError`;\n // branching on the name rather than the class keeps this reachable from a\n // test without fabricating a platform error.\n return unavailable(\n source,\n (error as { name?: string }).name === 'TimeoutError'\n ? 'timeout'\n : 'network',\n );\n }\n\n if (!upstream.ok) {\n return unavailable(\n source,\n REASON_FOR_STATUS[upstream.status] ?? 'unexpected',\n upstream.status,\n );\n }\n\n const body = await upstream.json().catch(() => undefined);\n const page = source.read(body);\n return page === undefined\n ? unavailable(source, 'unexpected', upstream.status)\n : {\n ok: true,\n group: {\n source: source.key,\n entity: source.entity,\n labelKey: source.labelKey,\n items: page.items,\n total: page.total,\n },\n };\n};\n\nconst clampLimit = (raw: string | null, fallback: number): number => {\n const asked = Number(raw);\n if (!Number.isInteger(asked) || asked < 1) return fallback;\n return Math.min(asked, MAX_GROUP_LIMIT);\n};\n\n/**\n * `GET /api/search` — records matching a term, across every source this host\n * mounts (ADR 0040).\n *\n * The caller's own session is the only credential that crosses: each source is\n * asked over the same guarded route its screens use, so what comes back is\n * already authorized for this principal and this organization. There is no\n * index, and there must never be one — a prefetched index is a tenant leak, and\n * it is the reason this is a fan-out rather than a search service.\n *\n * A source that fails is **named** in `unavailable` rather than dropped. An\n * absent group renders as \"no matches\", which is a confident claim the server is\n * in no position to make about a service it could not reach.\n */\nexport const createRecordSearchRoute = ({\n sources,\n timeoutMs = DEFAULT_SOURCE_TIMEOUT_MS,\n limit: defaultLimit = DEFAULT_GROUP_LIMIT,\n}: RecordSearchRouteOptions) => {\n const byKey = new Map(sources.map(source => [source.key, source]));\n\n return async function GET(request: Request): Promise<Response> {\n // The services decide what this caller may read; this only carries the\n // token. But the gate is not redundant: `catalog-reference` reads are\n // unauthenticated at the service by design, so without it a signed-out\n // caller gets brand matches back and the answer reads as a real search —\n // and the endpoint becomes an anonymous amplifier against that service.\n const token = await sessionToken();\n if (token === undefined) {\n return NextResponse.json(\n { error: 'unauthenticated', code: 'unauthenticated' },\n { status: 401 },\n );\n }\n\n const params = new URL(request.url).searchParams;\n\n const asked = (params.get(SEARCH_SOURCES_PARAM) ?? '')\n .split(',')\n .filter(name => name !== '');\n const unknown = asked.find(name => !byKey.has(name));\n if (unknown !== undefined) {\n // Loud rather than ignored: no keystroke can produce this, only a caller\n // naming a source that does not exist — and silently searching everything\n // instead would answer a question nobody asked.\n return NextResponse.json(\n {\n error: 'invalid query',\n code: 'invalidQuery',\n detail: `unknown search source \"${unknown}\"`,\n },\n { status: 400 },\n );\n }\n const selected =\n asked.length === 0\n ? sources\n : sources.filter(source => asked.includes(source.key));\n\n const term = (params.get(SEARCH_TERM_PARAM) ?? '').trim();\n const answer = (body: RecordSearchResponse) =>\n // The body is scoped to one principal and one organization. `no-store` is\n // correctness here, not tuning.\n NextResponse.json(body, { headers: { 'cache-control': 'no-store' } });\n\n if (term.length < MIN_TERM_LENGTH) {\n return answer({ term, groups: [], unavailable: [] });\n }\n\n const limit = clampLimit(params.get(SEARCH_LIMIT_PARAM), defaultLimit);\n const outcomes = await Promise.all(\n selected.map(source => runSource(source, term, limit, token, timeoutMs)),\n );\n\n return answer({\n term,\n // Declared order, never completion order — otherwise the list reshuffles\n // with the network and nobody can learn where anything lands.\n groups: outcomes.flatMap(outcome => (outcome.ok ? [outcome.group] : [])),\n unavailable: outcomes.flatMap(outcome =>\n outcome.ok ? [] : [outcome.unavailable],\n ),\n });\n };\n};\n"],"names":["NextResponse","bearerHeader","sessionToken","SEARCH_TERM_PARAM","SEARCH_SOURCES_PARAM","SEARCH_LIMIT_PARAM","MIN_TERM_LENGTH","DEFAULT_GROUP_LIMIT","MAX_GROUP_LIMIT","DEFAULT_SOURCE_TIMEOUT_MS","REASON_FOR_STATUS","unavailable","source","reason","status","ok","key","entity","undefined","runSource","term","limit","token","timeoutMs","upstream","fetch","url","headers","cache","signal","AbortSignal","timeout","error","name","body","json","catch","page","read","group","labelKey","items","total","clampLimit","raw","fallback","asked","Number","isInteger","Math","min","createRecordSearchRoute","sources","defaultLimit","byKey","Map","map","GET","request","params","code","URL","searchParams","get","split","filter","unknown","find","has","detail","selected","length","includes","trim","answer","groups","outcomes","Promise","all","flatMap","outcome"],"mappings":"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"}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { type Entity, type EntityConstructor } from '@entifix/core';
|
|
2
|
+
import type { RecordSearchOption } from './record-search.types.js';
|
|
3
|
+
/**
|
|
4
|
+
* What a host declares in order to make one entity searchable from the palette
|
|
5
|
+
* (ADR 0040).
|
|
6
|
+
*
|
|
7
|
+
* The search and label members are **declared**, not derived, and that is not an
|
|
8
|
+
* oversight in the metadata: `linkSearchProperty`/`linkLabelProperty` are
|
|
9
|
+
* properties of a *referring* accessor — they say how the owner of a relation
|
|
10
|
+
* looks up its target — and an entity has no member that says "this is what I am
|
|
11
|
+
* called". So the host that mounts the screens says it, next to the route those
|
|
12
|
+
* screens live at.
|
|
13
|
+
*/
|
|
14
|
+
export interface RecordSearchSourceConfig<TEntity extends Entity> {
|
|
15
|
+
/** Stable id, unique per host. Defaults to the entity's wire name. */
|
|
16
|
+
readonly key?: string;
|
|
17
|
+
readonly entityConstructor: EntityConstructor<TEntity>;
|
|
18
|
+
/** Where the owning service listens, **without** the `/api` suffix. */
|
|
19
|
+
readonly baseUrl: string;
|
|
20
|
+
/** The `filterable` string member a term is matched against with `like`. */
|
|
21
|
+
readonly searchProperty: string;
|
|
22
|
+
/** The `sortable` member read as a record's label. */
|
|
23
|
+
readonly labelProperty: string;
|
|
24
|
+
/** An optional secondary line — a code, a role. */
|
|
25
|
+
readonly sublabelProperty?: string;
|
|
26
|
+
/** The group heading, as the entity's own `pluralKey`. */
|
|
27
|
+
readonly labelKey: string;
|
|
28
|
+
/** Where selecting a record lands. Locale-free; the caller prefixes it. */
|
|
29
|
+
readonly href: (id: string) => string;
|
|
30
|
+
}
|
|
31
|
+
/** A declared source, reduced to what the fan-out actually calls. */
|
|
32
|
+
export interface RecordSearchSource {
|
|
33
|
+
readonly key: string;
|
|
34
|
+
readonly entity: string;
|
|
35
|
+
readonly labelKey: string;
|
|
36
|
+
/** The upstream URL for one term. */
|
|
37
|
+
readonly url: (term: string, limit: number) => string;
|
|
38
|
+
/** `undefined` when the body is not a readable `entityPage` envelope. */
|
|
39
|
+
readonly read: (body: unknown) => {
|
|
40
|
+
items: RecordSearchOption[];
|
|
41
|
+
total: number;
|
|
42
|
+
} | undefined;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Declare a record search source, validating it against the entity's own
|
|
46
|
+
* metadata **now** rather than on the first keystroke.
|
|
47
|
+
*
|
|
48
|
+
* Every check here guards a failure that is otherwise silent at both ends: the
|
|
49
|
+
* service answers `400`, and a caller renders that as a group it could not
|
|
50
|
+
* reach — which reads as "there are no products". `assertSearchable` took the
|
|
51
|
+
* same posture for the relation picker, for the same reason.
|
|
52
|
+
*
|
|
53
|
+
* ⚠️ This throws at **module load**, so a bad declaration fails the whole app at
|
|
54
|
+
* boot rather than just search. That is deliberate — a source silently missing
|
|
55
|
+
* from a palette is indistinguishable from a permission the caller lacks — but
|
|
56
|
+
* the blast radius is wider than the picker's, which fails only its own render.
|
|
57
|
+
*/
|
|
58
|
+
export declare const defineRecordSearchSource: <TEntity extends Entity>({ key, entityConstructor, baseUrl, searchProperty, labelProperty, sublabelProperty, labelKey, href, }: RecordSearchSourceConfig<TEntity>) => RecordSearchSource;
|
|
59
|
+
//# sourceMappingURL=record-search-source.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"record-search-source.d.ts","sourceRoot":"","sources":["../../../src/lib/search/record-search-source.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,MAAM,EACX,KAAK,iBAAiB,EAMvB,MAAM,eAAe,CAAC;AAGvB,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAEnE;;;;;;;;;;GAUG;AACH,MAAM,WAAW,wBAAwB,CAAC,OAAO,SAAS,MAAM;IAC9D,sEAAsE;IACtE,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,iBAAiB,EAAE,iBAAiB,CAAC,OAAO,CAAC,CAAC;IACvD,uEAAuE;IACvE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,4EAA4E;IAC5E,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,sDAAsD;IACtD,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,mDAAmD;IACnD,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;IACnC,0DAA0D;IAC1D,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,2EAA2E;IAC3E,QAAQ,CAAC,IAAI,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,MAAM,CAAC;CACvC;AAED,qEAAqE;AACrE,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,qCAAqC;IACrC,QAAQ,CAAC,GAAG,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC;IACtD,yEAAyE;IACzE,QAAQ,CAAC,IAAI,EAAE,CACb,IAAI,EAAE,OAAO,KACV;QAAE,KAAK,EAAE,kBAAkB,EAAE,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,GAAG,SAAS,CAAC;CACjE;AAoBD;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,wBAAwB,GAAI,OAAO,SAAS,MAAM,EAAE,uGAS9D,wBAAwB,CAAC,OAAO,CAAC,KAAG,kBAiItC,CAAC"}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
import { describeEntityColumns, EntifixLogicError, envelopeEntityName, readEntityPageEnvelope, serializeLoadRequestParams } from '@entifix/core';
|
|
3
|
+
import { Effect, Exit } from 'effect';
|
|
4
|
+
const descriptorOf = (columns, name)=>columns.find((column)=>column.name === name);
|
|
5
|
+
const rejection = (entityName, member, because, fix)=>new EntifixLogicError(`Cannot search ${entityName} by "${member}": ${because} ${fix}`, undefined, {
|
|
6
|
+
entity: entityName,
|
|
7
|
+
member
|
|
8
|
+
});
|
|
9
|
+
/**
|
|
10
|
+
* Declare a record search source, validating it against the entity's own
|
|
11
|
+
* metadata **now** rather than on the first keystroke.
|
|
12
|
+
*
|
|
13
|
+
* Every check here guards a failure that is otherwise silent at both ends: the
|
|
14
|
+
* service answers `400`, and a caller renders that as a group it could not
|
|
15
|
+
* reach — which reads as "there are no products". `assertSearchable` took the
|
|
16
|
+
* same posture for the relation picker, for the same reason.
|
|
17
|
+
*
|
|
18
|
+
* ⚠️ This throws at **module load**, so a bad declaration fails the whole app at
|
|
19
|
+
* boot rather than just search. That is deliberate — a source silently missing
|
|
20
|
+
* from a palette is indistinguishable from a permission the caller lacks — but
|
|
21
|
+
* the blast radius is wider than the picker's, which fails only its own render.
|
|
22
|
+
*/ export const defineRecordSearchSource = ({ key, entityConstructor, baseUrl, searchProperty, labelProperty, sublabelProperty, labelKey, href })=>{
|
|
23
|
+
const entity = envelopeEntityName(entityConstructor);
|
|
24
|
+
const entityName = entityConstructor.name;
|
|
25
|
+
const columns = describeEntityColumns(entityConstructor);
|
|
26
|
+
const search = descriptorOf(columns, searchProperty);
|
|
27
|
+
if (search === undefined) {
|
|
28
|
+
throw rejection(entityName, searchProperty, 'the entity declares no such member.', 'Name a member the entity actually has.');
|
|
29
|
+
}
|
|
30
|
+
// `filterable` is simultaneously the server-side RSQL allowlist.
|
|
31
|
+
if (!search.filterable) {
|
|
32
|
+
throw rejection(entityName, searchProperty, 'the member is not filterable, so the service would reject the query.', 'Declare it `filterable` on the entity, or search another member.');
|
|
33
|
+
}
|
|
34
|
+
// A `like` against anything else reaches `coerceValue`, which rejects a
|
|
35
|
+
// partial term — so an enum member reads as declarable and is permanently
|
|
36
|
+
// broken: every keystroke short of a whole value answers `400`.
|
|
37
|
+
if (search.type !== 'string') {
|
|
38
|
+
throw rejection(entityName, searchProperty, `the member is a ${search.type}, and a partial term is not a valid value for it.`, 'Search a string member instead.');
|
|
39
|
+
}
|
|
40
|
+
const label = descriptorOf(columns, labelProperty);
|
|
41
|
+
if (label === undefined) {
|
|
42
|
+
throw rejection(entityName, labelProperty, 'the entity declares no such label member.', 'Name a member the entity actually has.');
|
|
43
|
+
}
|
|
44
|
+
// The group is sorted by the label, so the flag is load-bearing: without it
|
|
45
|
+
// the service rejects the sort and the group degrades whole. Unsorted was the
|
|
46
|
+
// alternative and is worse — the services apply no default order, so results
|
|
47
|
+
// would arrive in storage order and shuffle as records are rewritten.
|
|
48
|
+
if (!label.sortable) {
|
|
49
|
+
throw rejection(entityName, labelProperty, 'the label member is not sortable, so the service would reject the sort.', 'Declare it `sortable` on the entity, or label records by another member.');
|
|
50
|
+
}
|
|
51
|
+
let sublabel;
|
|
52
|
+
if (sublabelProperty !== undefined) {
|
|
53
|
+
sublabel = descriptorOf(columns, sublabelProperty);
|
|
54
|
+
if (sublabel === undefined) {
|
|
55
|
+
throw rejection(entityName, sublabelProperty, 'the entity declares no such sublabel member.', 'Name a member the entity actually has, or drop `sublabelProperty`.');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const read = (body)=>{
|
|
59
|
+
const page = Effect.runSyncExit(readEntityPageEnvelope(entityConstructor, body));
|
|
60
|
+
if (Exit.isFailure(page)) return undefined;
|
|
61
|
+
const items = page.value.items.filter((record)=>record.id != null).map((record)=>{
|
|
62
|
+
const row = record;
|
|
63
|
+
const raw = row[labelProperty];
|
|
64
|
+
const id = String(record.id);
|
|
65
|
+
return _extends({
|
|
66
|
+
id,
|
|
67
|
+
// A blank label is as useless as a missing one, so both fall back to
|
|
68
|
+
// the id — an unnamed record must stay distinguishable from the one
|
|
69
|
+
// above it. Same rule the relation picker's `labelOf` applies.
|
|
70
|
+
label: raw == null || raw === '' ? id : String(raw)
|
|
71
|
+
}, sublabel !== undefined && row[sublabel.name] != null ? {
|
|
72
|
+
sublabel: String(row[sublabel.name])
|
|
73
|
+
} : {}, {
|
|
74
|
+
entity,
|
|
75
|
+
href: href(id)
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
return {
|
|
79
|
+
items,
|
|
80
|
+
total: page.value.total
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
return {
|
|
84
|
+
key: key != null ? key : entity,
|
|
85
|
+
entity,
|
|
86
|
+
labelKey,
|
|
87
|
+
/**
|
|
88
|
+
* Built through `serializeLoadRequestParams`, never by hand. That is
|
|
89
|
+
* correctness rather than tidiness: `encodeRsqlValue` quotes a term carrying
|
|
90
|
+
* any of `;,()'"=!<>\` or whitespace, so `Acme, Inc.` stays one value
|
|
91
|
+
* instead of splitting the expression into two comparisons.
|
|
92
|
+
*/ url: (term, limit)=>{
|
|
93
|
+
const request = {
|
|
94
|
+
filtering: [
|
|
95
|
+
{
|
|
96
|
+
property: search.key,
|
|
97
|
+
operator: 'like',
|
|
98
|
+
value: term
|
|
99
|
+
}
|
|
100
|
+
],
|
|
101
|
+
sorting: [
|
|
102
|
+
{
|
|
103
|
+
0: {
|
|
104
|
+
property: label.key,
|
|
105
|
+
type: 'asc'
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
],
|
|
109
|
+
page: 1,
|
|
110
|
+
pageSize: limit
|
|
111
|
+
};
|
|
112
|
+
return `${baseUrl}/api/${entity}?${serializeLoadRequestParams(request).toString()}`;
|
|
113
|
+
},
|
|
114
|
+
read
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
//# sourceMappingURL=record-search-source.js.map
|