@entifix/next-shell 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lib/command-palette/use-records-source.d.ts.map +1 -1
- package/dist/lib/command-palette/use-records-source.js +5 -11
- package/dist/lib/command-palette/use-records-source.js.map +1 -1
- package/dist/lib/command-palette/use-use-cases-source.d.ts.map +1 -1
- package/dist/lib/command-palette/use-use-cases-source.js +2 -5
- package/dist/lib/command-palette/use-use-cases-source.js.map +1 -1
- package/dist/lib/config/problem-response.d.ts +12 -0
- package/dist/lib/config/problem-response.d.ts.map +1 -0
- package/dist/lib/config/problem-response.js +21 -0
- package/dist/lib/config/problem-response.js.map +1 -0
- package/dist/lib/config/service-proxy-route.d.ts +0 -10
- package/dist/lib/config/service-proxy-route.d.ts.map +1 -1
- package/dist/lib/config/service-proxy-route.js +39 -28
- package/dist/lib/config/service-proxy-route.js.map +1 -1
- package/dist/lib/crud/entity-crud-form.d.ts +2 -2
- package/dist/lib/crud/entity-crud-form.d.ts.map +1 -1
- package/dist/lib/crud/entity-crud-form.js.map +1 -1
- package/dist/lib/crud/make-entity-crud.d.ts.map +1 -1
- package/dist/lib/crud/make-entity-crud.js +16 -13
- package/dist/lib/crud/make-entity-crud.js.map +1 -1
- package/dist/lib/i18n/spec-catalog.js +2 -1
- package/dist/lib/i18n/spec-catalog.js.map +1 -1
- package/dist/lib/search/record-search-route.d.ts +1 -1
- package/dist/lib/search/record-search-route.d.ts.map +1 -1
- package/dist/lib/search/record-search-route.js +23 -27
- package/dist/lib/search/record-search-route.js.map +1 -1
- package/dist/lib/search/record-search-source.d.ts.map +1 -1
- package/dist/lib/search/record-search-source.js +2 -5
- package/dist/lib/search/record-search-source.js.map +1 -1
- package/dist/lib/search/record-search.types.d.ts +11 -1
- package/dist/lib/search/record-search.types.d.ts.map +1 -1
- package/dist/lib/search/record-search.types.js +0 -19
- package/dist/lib/search/record-search.types.js.map +1 -1
- package/dist/server.d.ts +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1 -0
- package/dist/server.js.map +1 -1
- package/package.json +11 -11
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/lib/crud/make-entity-crud.tsx"],"sourcesContent":["'use client';\n\nimport {\n deleteUCFactory,\n type EntityRepositoryTag,\n getUCFactory,\n loadUCFactory,\n saveUCFactory,\n} from '@entifix/business';\nimport {\n describeEntityColumns,\n EntifixBuildError,\n type EntifixError,\n EntifixLogicError,\n type Entity,\n type EntityConstructor,\n type EntityMetadataSource,\n envelopeEntityName,\n extractMetaEntity,\n type FilterGroup,\n} from '@entifix/core';\nimport { EntityTable, useCasesForSurface } from '@entifix/react-controls';\nimport {\n entityQueryScope,\n useDataLoading,\n useEntityMutation,\n useEntityRecord,\n} from '@entifix/react-integration';\nimport type { TransactionSink } from '@entifix/transactions';\nimport { TransactionSinkTag } from '@entifix/transactions';\nimport { useQueryClient } from '@tanstack/react-query';\nimport { Context } from 'effect';\nimport { useParams, useRouter } from 'next/navigation';\nimport { useState } from 'react';\n\nimport { useLocaleHref } from '../i18n/index.js';\nimport { useEntityNavHost } from '../workspace/entity-nav.js';\nimport {\n pendingFor,\n pendingRecordsFor,\n usePendingTransactions,\n} from '../workspace/pending-transactions.js';\nimport { EntityCrudForm } from './entity-crud-form.js';\nimport { handOffWrite } from './hand-off-write.js';\nimport type {\n AnyFilterGroup,\n EntityCrud,\n EntityCrudLinkSource,\n EntityCrudOptions,\n EntityCrudSingleViewProps,\n} from './make-entity-crud.types.js';\nimport { PendingNotice } from './pending-notice.js';\nimport { CATALOG_NEW_SLUG, slugToEntityId } from './slug.js';\nimport { useCrudRenderLink } from './use-crud-render-link.js';\nimport { useEntityAffordances } from './use-entity-affordances.js';\nimport { useEntityBulk } from './use-entity-bulk.js';\n\n/** What a picker defaults to reading off its target. */\nconst TARGET_NAME_PROPERTY = 'name';\n\n/**\n * What the generated pages' use-cases run against.\n *\n * Exported because a hand-built screen needs the same three: a wizard reads\n * records, resolves pickers and writes through exactly these ports, and\n * rebuilding the union at each such call site is how one of them ends up\n * missing `TransactionSinkTag` and quietly losing its optimistic treatment.\n */\nexport type CrudContext = EntityRepositoryTag | TransactionSinkTag;\n\n/**\n * Adds the pending-write sink to one repository adapter, giving the context the\n * use-cases run against.\n *\n * The cast is the price of naming adapters by key: `AdapterKey` narrows which\n * names may be *offered*, but TypeScript will not carry that narrowing through\n * an index access on a generic record.\n */\nexport function mergeCrudContext<TAdapters>(\n adapters: TAdapters,\n repository: keyof TAdapters,\n sink: TransactionSink,\n): Context.Context<CrudContext> {\n return Context.add(\n adapters[repository] as Context.Context<EntityRepositoryTag>,\n TransactionSinkTag,\n sink,\n );\n}\n\n/**\n * Builds the list page and the single-record page for one entity.\n *\n * Every catalog entity used to cost ~10 files and ~300 lines whose only variable\n * was a class name: two organisms with a `.types.ts` each, two client pages, two\n * route files. The route files stay (Next needs a module per path); everything\n * between them is derived from the entity's own metadata, so a new reference\n * entity is this call plus a nav entry.\n *\n * Two things are checked here rather than at the call site, because both fail\n * silently otherwise. The catalog key must be the entity's own `@entity({ key })`\n * — they are the same string by convention, and a drifted one titles the form\n * after a different entity in a way only a reader of both catalogs would catch.\n * And a `links` entry must name a real member — a picker aimed at nothing renders\n * identically to a read-only field.\n *\n * **Serves compile-time `@entity()` classes only.** Operator reference data ships\n * with the code, so making this spec-driven would trade type safety for nothing;\n * a vendor-authored `EntitySpecification` renders through a separate path.\n */\n/**\n * A rejected verb, as something the form's error slot can render.\n *\n * `useErrorMessage` resolves `details.code` through the shared `errors`\n * catalog, so a shell that throws an `EntifixError` carrying one gets its\n * translated sentence; anything else — a network failure, a thrown string —\n * keeps its message rather than being relabelled with a code it never had.\n */\nfunction asEntifixError(failure: unknown): EntifixError {\n return failure instanceof EntifixLogicError ||\n failure instanceof EntifixBuildError\n ? failure\n : new EntifixLogicError(\n failure instanceof Error ? failure.message : String(failure),\n );\n}\n\n/**\n * Runs each link's `useBaseFiltering` against the record being edited, in link\n * order, with `undefined` for a link that declares none.\n *\n * Legal for the reason `useEntityLinkSources` gives: the links are a\n * factory-time array, and whether a link carries a rule is fixed with it, so\n * the same hooks run in the same order on every render. The lint rule cannot\n * see a hook called through a member, so there is nothing for it to flag.\n */\nfunction useLinkFilters<TEntity extends Entity>(\n links: readonly {\n readonly useBaseFiltering?: (\n record: TEntity | undefined,\n ) => AnyFilterGroup | undefined;\n }[],\n record: TEntity | undefined,\n): (AnyFilterGroup | undefined)[] {\n return links.map(link => link.useBaseFiltering?.(record));\n}\n\nexport function makeEntityCrud<TEntity extends Entity, TAdapters>(\n entityConstructor: EntityConstructor<TEntity>,\n options: EntityCrudOptions<TAdapters, TEntity>,\n): EntityCrud<TEntity> {\n const {\n useAdapters,\n basePath,\n catalogKey,\n repository,\n hiddenFields = [],\n columns,\n toolbar,\n pageSize,\n links = [],\n metadataSource: givenMetadataSource,\n metadata,\n runBulkUseCase,\n runUseCase,\n } = options;\n\n if (givenMetadataSource !== undefined && metadata !== undefined) {\n throw new EntifixBuildError(\n `${entityConstructor.name} was given both metadataSource and metadata; pass one`,\n );\n }\n // An adapter value, so it is resolved per render from the same `useAdapters()`\n // the records come from — the host's composition, never the caller's module.\n const metadataSourceOf = (adapters: TAdapters) =>\n metadata === undefined\n ? givenMetadataSource\n : (adapters[metadata] as EntityMetadataSource);\n\n const meta = extractMetaEntity(entityConstructor);\n if (meta.key !== catalogKey) {\n throw new EntifixBuildError(\n `${entityConstructor.name} declares key \"${String(meta.key)}\" but was given catalog key \"${catalogKey}\"`,\n );\n }\n // Read off `@entity()` rather than rebuilt from `catalogKey`. Both spellings\n // produce the same string today, and that is exactly the problem: a second\n // place that knows how an entity's catalog subtree is laid out is a second\n // place to fix when one moves. `MetaEntityOptions` makes them optional, so an\n // entity that never declared them fails here — where a screen is generated —\n // rather than on the render that first tries to title a tab with `undefined`.\n const { labelKey: entityLabelKey, pluralKey: entityPluralKey } = meta;\n if (entityLabelKey === undefined || entityPluralKey === undefined) {\n throw new EntifixBuildError(\n `${entityConstructor.name} must declare labelKey and pluralKey on @entity() to be generated`,\n );\n }\n\n // Computed once: a descriptor is a property of the class, so nothing about it\n // depends on the record being edited. `linkLabelProperty`/`linkSearchProperty`\n // are overridden here because a scalar foreign key's `@accessor()` cannot name\n // the target's members — it may not import the target at all.\n // The wire name, which is also what a pending entry records and what\n // `entityQueryScopeFor` keys on — one derivation, so a notice and an\n // invalidation can never disagree about which entity they mean.\n const entityName = envelopeEntityName(entityConstructor);\n const descriptors = describeEntityColumns(entityConstructor);\n const linkPlans = links.map(link => {\n const descriptor = descriptors.find(entry => entry.name === link.field);\n if (descriptor === undefined) {\n throw new EntifixBuildError(\n `${entityConstructor.name} has no member \"${link.field}\" for a link source`,\n );\n }\n return {\n ...link,\n descriptor: {\n ...descriptor,\n linkLabelProperty: link.labelProperty ?? TARGET_NAME_PROPERTY,\n linkSearchProperty: link.searchProperty ?? TARGET_NAME_PROPERTY,\n },\n };\n });\n\n function ListPage() {\n const adapters = useAdapters();\n // Outside a `PendingTransactionsProvider` this watches nothing, so a host\n // that has not mounted one behaves exactly as it did before.\n const pending = usePendingTransactions();\n // Every internal href carries the locale. An unprefixed one still resolves —\n // the middleware redirects it — but the visitor pays a round trip per click.\n const withLocale = useLocaleHref();\n\n const queryClient = useQueryClient();\n const scope = entityQueryScope(entityConstructor);\n const metadataSource = metadataSourceOf(adapters);\n const affordances = useEntityAffordances(entityConstructor, metadataSource);\n const renderLink = useCrudRenderLink(catalogKey);\n\n // Records this browser has created but the service has not finished\n // writing. Prepended below rather than patched into the cache, which a\n // refetch would undo.\n const optimistic = pendingRecordsFor<TEntity>(pending, entityName);\n\n const pager = useDataLoading<TEntity, CrudContext>({\n uc: loadUCFactory<TEntity>(),\n ctx: mergeCrudContext(adapters, repository, pending),\n // Scoped rather than left to the per-instance fallback, which is correct\n // but unshared: with the entity's own scope one invalidation refreshes\n // every page and filter of it, which is what a bulk run needs — and it is\n // the same prefix `useReactiveInvalidation`already targets, so a generated\n // list now also refreshes on a reactive change event.\n queryKey: scope,\n ...(pageSize === undefined ? {} : { initialPageSize: pageSize }),\n });\n\n // Only when this caller may actually run something over a selection.\n //\n // Measured live: an `admin` holds `catalog-reference:*:read` and no\n // `retire`, so the service filters the verb out of `$metadata` — but the\n // selection column still rendered, offering a set that no action could be\n // taken on. A checkbox that can lead nowhere is worse than no checkbox: it\n // reads as a permission the user does not have.\n //\n // Decided from the served document rather than from the runner, because\n // the runner is a property of the *shell* (it knows the route) and the\n // grant is a property of the *caller*. The column therefore appears when\n // the document lands, which is the asynchrony serving affordances accepts\n // for every action surface.\n const canRunBulk =\n useCasesForSurface('bulk-bar', affordances.metadata?.useCases).length > 0;\n\n const bulk = useEntityBulk<TEntity>({\n run: canRunBulk ? runBulkUseCase : undefined,\n // A bulk write changed the rows underneath the listing, so the page is\n // re-read; the selection deliberately survives it (\"the selection\n // is still there afterwards\"), because the operator's next act is usually\n // to retry the failures or run a second verb on the same rows.\n onCompleted: () => {\n void queryClient.invalidateQueries({ queryKey: scope });\n },\n });\n\n // What a row's overflow menu runs, and the one place the list reports a\n // verb that failed.\n //\n // ⚠️ **A row verb and the form's are the same runner with different\n // arguments.** `EntityTable` hands back the whole record, because that is\n // what it has; `runUseCase` takes an id, because that is what a route needs\n // The list has no error slot of its own, so a failure lands in the\n // table's own error region — the same one a failed page read uses.\n const [useCaseError, setUseCaseError] = useState<EntifixError | undefined>(\n undefined,\n );\n // Called unconditionally and the *result* gated, the way the surfaces are\n // read in `EntityForm`: the name begins with `use`, so short-circuiting it\n // reads to `react-hooks/rules-of-hooks` as a conditional hook.\n const rowUseCases = useCasesForSurface(\n 'row-menu',\n affordances.metadata?.useCases,\n );\n const canRunRowUseCase = runUseCase !== undefined && rowUseCases.length > 0;\n\n const handleRowUseCase = canRunRowUseCase\n ? (key: string, item: TEntity) => {\n if (item.id == null) return;\n setUseCaseError(undefined);\n void runUseCase(key, item.id).then(\n () => queryClient.invalidateQueries({ queryKey: scope }),\n (failure: unknown) => setUseCaseError(asEntifixError(failure)),\n );\n }\n : undefined;\n\n return (\n <div className=\"flex flex-col gap-s\">\n {/* Above the table, because this is where `afterSave()` leaves the\n operator after a create — and a create is the only transactional\n write there is, so this is the surface the pending state has. */}\n <PendingNotice\n entries={pendingFor(pending, entityName)}\n onDismiss={pending.dismiss}\n />\n <EntityTable\n entityConstructor={entityConstructor}\n {...pager}\n items={[...optimistic, ...pager.items]}\n totalItems={pager.totalItems + optimistic.length}\n hrefFor={id => withLocale(`${basePath}/${String(id)}`)}\n newHref={withLocale(`${basePath}/${CATALOG_NEW_SLUG}`)}\n renderLink={renderLink}\n {...affordances}\n {...bulk.tableProps}\n onUseCase={handleRowUseCase}\n error={useCaseError ?? pager.error}\n >\n {columns}\n {toolbar}\n </EntityTable>\n </div>\n );\n }\n\n /**\n * Dual-host: as a route it reads its slug from the URL and returns to the list\n * on save; in a workspace tab it takes the slug and the post-save action as\n * props.\n */\n function SingleViewPage({\n slug,\n onSaved,\n onDeleted,\n draft,\n }: EntityCrudSingleViewProps = {}) {\n const adapters = useAdapters();\n const metadataSource = metadataSourceOf(adapters);\n const pending = usePendingTransactions();\n const router = useRouter();\n const withLocale = useLocaleHref();\n const params = useParams<{ slug: string }>();\n // A verb's failure is this page's, not the record fetch's or the mutation's,\n // so it needs a slot of its own to reach the form's error area.\n const [useCaseError, setUseCaseError] = useState<EntifixError | undefined>(\n undefined,\n );\n const id = slugToEntityId(slug ?? params.slug);\n\n const ctx = mergeCrudContext(adapters, repository, pending);\n\n const {\n entity,\n isLoading,\n error: loadError,\n reload,\n } = useEntityRecord<TEntity, CrudContext>({\n uc: getUCFactory<TEntity>(),\n ctx,\n id,\n });\n\n const {\n save,\n remove,\n isSaving,\n isDeleting,\n error: writeError,\n } = useEntityMutation<TEntity, CrudContext>({\n saveUc: saveUCFactory<TEntity>(),\n deleteUc: deleteUCFactory<TEntity>(),\n ctx,\n });\n\n // Against the record as loaded, not the draft: the rule is about which\n // record this is, not what the operator has typed into it.\n const linkFilters = useLinkFilters(linkPlans, entity);\n\n // Rebuilt inline on every render on purpose: `useEntityLinkSource` holds\n // these in a ref and keeps them out of its query keys, precisely so a caller\n // does not have to memoise them.\n const linkSources: EntityCrudLinkSource[] = linkPlans.map(\n (plan, index) => ({\n field: plan.field,\n descriptor: plan.descriptor,\n config: {\n entityConstructor: plan.entityConstructor,\n loadUc: loadUCFactory(),\n getUc: getUCFactory(),\n ctx: mergeCrudContext(adapters, plan.repository, pending),\n // The config's target is erased to `Entity`, and the host's filter to\n // `any`; the two meet here, where neither type is the real target's.\n baseFiltering: linkFilters[index] as FilterGroup<Entity> | undefined,\n },\n }),\n );\n\n // Inside a workspace tab, \"back to the list\" is the list's tab, not a URL\n // that would leave the workspace. A host's own callbacks still win.\n const host = useEntityNavHost();\n const renderLink = useCrudRenderLink(catalogKey);\n const queryClient = useQueryClient();\n // The list this page returns to has its page cached, and it is no longer\n // true. Invalidated rather than patched, for the reason `handOffWrite`\n // gives: a transactional create is not in the service yet, and the pending\n // set, not the cache, is what keeps it visible until it is.\n //\n // ⚠️ Invisible while every Open was a document load, which threw the cache\n // away anyway (#20): the first client-side navigation back to the list\n // showed the record as it was before the save.\n const refreshList = () =>\n queryClient.invalidateQueries({\n queryKey: entityQueryScope(entityConstructor),\n });\n const toList = () =>\n host === undefined\n ? router.push(withLocale(basePath))\n : host.toList(catalogKey);\n const afterSave = onSaved ?? toList;\n const afterDelete = onDeleted ?? toList;\n\n // The draft is spent once the write *commits*, and only here is that known:\n // `useEntityForm` neither fetches nor saves, so it cannot clear its own.\n // A failed mutation deliberately keeps the draft — the edit is still the\n // user's only copy of what they typed.\n const handleSave = async (next: TEntity) => {\n const saved = await save(next);\n if (saved === undefined) return;\n\n // ⚠️ Deliberately *not* a `setQueriesData` patch. The list refetches on\n // mount — precisely when the operator arrives, having just been navigated\n // here — and the server legitimately does not hold the record yet, so the\n // refetch would replace the patched page and the row would vanish a\n // moment after appearing. The pending set outlives refetches.\n handOffWrite({ id: String(saved.id), record: saved, pending, draft });\n void refreshList();\n afterSave();\n };\n\n const handleDelete = async () => {\n if (await remove(id)) {\n draft?.clear();\n void refreshList();\n afterDelete();\n }\n };\n\n /**\n * An `entity`-bound verb, on the record being shown.\n *\n * It **reloads** rather than patching what it has: the service decides the\n * resulting state — a `publish` may land on `published` from four different\n * places — and a client that assumed the new value would show the wrong one\n * the first time the rule changed. `id == null` means the create form,\n * where there is no record to act on and the form renders no verbs anyway.\n *\n * ⚠️ The rejection is **caught and shown**, never left to the promise.\n * `EntityForm`'s `onUseCase` returns `void`, so an uncaught rejection here\n * reaches the console and nothing else — measured on an offering refusing\n * `unpublish` from `draft`: the service answered `409` with a coded body,\n * the record correctly did not move, and the operator saw a button that\n * appeared to do nothing at all.\n */\n const handleUseCase =\n runUseCase === undefined || id == null\n ? undefined\n : (key: string) => {\n setUseCaseError(undefined);\n void runUseCase(key, id).then(\n () => reload(),\n (failure: unknown) => setUseCaseError(asEntifixError(failure)),\n );\n };\n\n return (\n <EntityCrudForm<TEntity>\n // Remounts (and reseeds the fields) once the record arrives.\n key={String(entity?.id ?? CATALOG_NEW_SLUG)}\n entityConstructor={entityConstructor}\n catalogKey={catalogKey}\n metadataSource={metadataSource}\n hiddenFields={hiddenFields}\n links={linkSources}\n entity={entity}\n isLoading={isLoading}\n isSaving={isSaving}\n isDeleting={isDeleting}\n error={loadError ?? writeError ?? useCaseError}\n onSave={handleSave}\n onDelete={id == null ? undefined : handleDelete}\n onUseCase={handleUseCase}\n backHref={withLocale(basePath)}\n renderLink={renderLink}\n draft={draft}\n />\n );\n }\n\n return {\n entityConstructor,\n entityKey: catalogKey,\n entityLabelKey,\n entityPluralKey,\n basePath,\n ListPage,\n SingleViewPage,\n };\n}\n"],"names":["deleteUCFactory","getUCFactory","loadUCFactory","saveUCFactory","describeEntityColumns","EntifixBuildError","EntifixLogicError","envelopeEntityName","extractMetaEntity","EntityTable","useCasesForSurface","entityQueryScope","useDataLoading","useEntityMutation","useEntityRecord","TransactionSinkTag","useQueryClient","Context","useParams","useRouter","useState","useLocaleHref","useEntityNavHost","pendingFor","pendingRecordsFor","usePendingTransactions","EntityCrudForm","handOffWrite","PendingNotice","CATALOG_NEW_SLUG","slugToEntityId","useCrudRenderLink","useEntityAffordances","useEntityBulk","TARGET_NAME_PROPERTY","mergeCrudContext","adapters","repository","sink","add","asEntifixError","failure","Error","message","String","useLinkFilters","links","record","map","link","useBaseFiltering","makeEntityCrud","entityConstructor","options","useAdapters","basePath","catalogKey","hiddenFields","columns","toolbar","pageSize","metadataSource","givenMetadataSource","metadata","runBulkUseCase","runUseCase","undefined","name","metadataSourceOf","meta","key","labelKey","entityLabelKey","pluralKey","entityPluralKey","entityName","descriptors","linkPlans","descriptor","find","entry","field","linkLabelProperty","labelProperty","linkSearchProperty","searchProperty","ListPage","affordances","pending","withLocale","queryClient","scope","renderLink","optimistic","pager","uc","ctx","queryKey","initialPageSize","canRunBulk","useCases","length","bulk","run","onCompleted","invalidateQueries","useCaseError","setUseCaseError","rowUseCases","canRunRowUseCase","handleRowUseCase","item","id","then","div","className","entries","onDismiss","dismiss","items","totalItems","hrefFor","newHref","tableProps","onUseCase","error","SingleViewPage","slug","onSaved","onDeleted","draft","loadError","router","params","entity","isLoading","reload","save","remove","isSaving","isDeleting","writeError","saveUc","deleteUc","linkFilters","linkSources","plan","index","config","loadUc","getUc","baseFiltering","host","refreshList","toList","push","afterSave","afterDelete","handleSave","next","saved","handleDelete","clear","handleUseCase","onSave","onDelete","backHref","entityKey"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../src/lib/crud/make-entity-crud.tsx"],"sourcesContent":["'use client';\n\nimport {\n deleteUCFactory,\n type EntityRepositoryTag,\n getUCFactory,\n loadUCFactory,\n saveUCFactory,\n} from '@entifix/business';\nimport {\n describeEntityColumns,\n type Entity,\n type EntityConstructor,\n type EntityMetadataSource,\n envelopeEntityName,\n extractMetaEntity,\n type FilterGroup,\n Internal,\n type TransitionalFailure,\n} from '@entifix/core';\nimport { EntityTable, useCasesForSurface } from '@entifix/react-controls';\nimport {\n entityQueryScope,\n useDataLoading,\n useEntityMutation,\n useEntityRecord,\n} from '@entifix/react-integration';\nimport type { TransactionSink } from '@entifix/transactions';\nimport { TransactionSinkTag } from '@entifix/transactions';\nimport { useQueryClient } from '@tanstack/react-query';\nimport { Context } from 'effect';\nimport { useParams, useRouter } from 'next/navigation';\nimport { useState } from 'react';\n\nimport { useLocaleHref } from '../i18n/index.js';\nimport { useEntityNavHost } from '../workspace/entity-nav.js';\nimport {\n pendingFor,\n pendingRecordsFor,\n usePendingTransactions,\n} from '../workspace/pending-transactions.js';\nimport { EntityCrudForm } from './entity-crud-form.js';\nimport { handOffWrite } from './hand-off-write.js';\nimport type {\n AnyFilterGroup,\n EntityCrud,\n EntityCrudLinkSource,\n EntityCrudOptions,\n EntityCrudSingleViewProps,\n} from './make-entity-crud.types.js';\nimport { PendingNotice } from './pending-notice.js';\nimport { CATALOG_NEW_SLUG, slugToEntityId } from './slug.js';\nimport { useCrudRenderLink } from './use-crud-render-link.js';\nimport { useEntityAffordances } from './use-entity-affordances.js';\nimport { useEntityBulk } from './use-entity-bulk.js';\n\n/** What a picker defaults to reading off its target. */\nconst TARGET_NAME_PROPERTY = 'name';\n\n/**\n * What the generated pages' use-cases run against.\n *\n * Exported because a hand-built screen needs the same three: a wizard reads\n * records, resolves pickers and writes through exactly these ports, and\n * rebuilding the union at each such call site is how one of them ends up\n * missing `TransactionSinkTag` and quietly losing its optimistic treatment.\n */\nexport type CrudContext = EntityRepositoryTag | TransactionSinkTag;\n\n/**\n * Adds the pending-write sink to one repository adapter, giving the context the\n * use-cases run against.\n *\n * The cast is the price of naming adapters by key: `AdapterKey` narrows which\n * names may be *offered*, but TypeScript will not carry that narrowing through\n * an index access on a generic record.\n */\nexport function mergeCrudContext<TAdapters>(\n adapters: TAdapters,\n repository: keyof TAdapters,\n sink: TransactionSink,\n): Context.Context<CrudContext> {\n return Context.add(\n adapters[repository] as Context.Context<EntityRepositoryTag>,\n TransactionSinkTag,\n sink,\n );\n}\n\n/**\n * Builds the list page and the single-record page for one entity.\n *\n * Every catalog entity used to cost ~10 files and ~300 lines whose only variable\n * was a class name: two organisms with a `.types.ts` each, two client pages, two\n * route files. The route files stay (Next needs a module per path); everything\n * between them is derived from the entity's own metadata, so a new reference\n * entity is this call plus a nav entry.\n *\n * Two things are checked here rather than at the call site, because both fail\n * silently otherwise. The catalog key must be the entity's own `@entity({ key })`\n * — they are the same string by convention, and a drifted one titles the form\n * after a different entity in a way only a reader of both catalogs would catch.\n * And a `links` entry must name a real member — a picker aimed at nothing renders\n * identically to a read-only field.\n *\n * **Serves compile-time `@entity()` classes only.** Operator reference data ships\n * with the code, so making this spec-driven would trade type safety for nothing;\n * a vendor-authored `EntitySpecification` renders through a separate path.\n */\n/**\n * A rejected verb, as something the form's error slot can render.\n *\n * A typed failure is kept as it is: `useErrorMessage` translates its own code,\n * a business refusal and an outage alike, and the failure toast that tells\n * them apart arrives with the optimistic API. Anything else that carries a\n * message keeps it rather than being relabelled with a code it never had; a\n * thrown value with no message at all is `Internal`.\n */\nfunction asFailure(failure: unknown): TransitionalFailure {\n // A typed failure is an `Error` too — Effect's tagged errors extend it — so\n // one check keeps both it and a legacy error carrying its own message.\n return failure instanceof Error\n ? (failure as TransitionalFailure)\n : new Internal();\n}\n\n/**\n * Runs each link's `useBaseFiltering` against the record being edited, in link\n * order, with `undefined` for a link that declares none.\n *\n * Legal for the reason `useEntityLinkSources` gives: the links are a\n * factory-time array, and whether a link carries a rule is fixed with it, so\n * the same hooks run in the same order on every render. The lint rule cannot\n * see a hook called through a member, so there is nothing for it to flag.\n */\nfunction useLinkFilters<TEntity extends Entity>(\n links: readonly {\n readonly useBaseFiltering?: (\n record: TEntity | undefined,\n ) => AnyFilterGroup | undefined;\n }[],\n record: TEntity | undefined,\n): (AnyFilterGroup | undefined)[] {\n return links.map(link => link.useBaseFiltering?.(record));\n}\n\nexport function makeEntityCrud<TEntity extends Entity, TAdapters>(\n entityConstructor: EntityConstructor<TEntity>,\n options: EntityCrudOptions<TAdapters, TEntity>,\n): EntityCrud<TEntity> {\n const {\n useAdapters,\n basePath,\n catalogKey,\n repository,\n hiddenFields = [],\n columns,\n toolbar,\n pageSize,\n links = [],\n metadataSource: givenMetadataSource,\n metadata,\n runBulkUseCase,\n runUseCase,\n } = options;\n\n if (givenMetadataSource !== undefined && metadata !== undefined) {\n throw new Error(\n `${entityConstructor.name} was given both metadataSource and metadata; pass one`,\n );\n }\n // An adapter value, so it is resolved per render from the same `useAdapters()`\n // the records come from — the host's composition, never the caller's module.\n const metadataSourceOf = (adapters: TAdapters) =>\n metadata === undefined\n ? givenMetadataSource\n : (adapters[metadata] as EntityMetadataSource);\n\n const meta = extractMetaEntity(entityConstructor);\n if (meta.key !== catalogKey) {\n throw new Error(\n `${entityConstructor.name} declares key \"${String(meta.key)}\" but was given catalog key \"${catalogKey}\"`,\n );\n }\n // Read off `@entity()` rather than rebuilt from `catalogKey`. Both spellings\n // produce the same string today, and that is exactly the problem: a second\n // place that knows how an entity's catalog subtree is laid out is a second\n // place to fix when one moves. `MetaEntityOptions` makes them optional, so an\n // entity that never declared them fails here — where a screen is generated —\n // rather than on the render that first tries to title a tab with `undefined`.\n const { labelKey: entityLabelKey, pluralKey: entityPluralKey } = meta;\n if (entityLabelKey === undefined || entityPluralKey === undefined) {\n throw new Error(\n `${entityConstructor.name} must declare labelKey and pluralKey on @entity() to be generated`,\n );\n }\n\n // Computed once: a descriptor is a property of the class, so nothing about it\n // depends on the record being edited. `linkLabelProperty`/`linkSearchProperty`\n // are overridden here because a scalar foreign key's `@accessor()` cannot name\n // the target's members — it may not import the target at all.\n // The wire name, which is also what a pending entry records and what\n // `entityQueryScopeFor` keys on — one derivation, so a notice and an\n // invalidation can never disagree about which entity they mean.\n const entityName = envelopeEntityName(entityConstructor);\n const descriptors = describeEntityColumns(entityConstructor);\n const linkPlans = links.map(link => {\n const descriptor = descriptors.find(entry => entry.name === link.field);\n if (descriptor === undefined) {\n throw new Error(\n `${entityConstructor.name} has no member \"${link.field}\" for a link source`,\n );\n }\n return {\n ...link,\n descriptor: {\n ...descriptor,\n linkLabelProperty: link.labelProperty ?? TARGET_NAME_PROPERTY,\n linkSearchProperty: link.searchProperty ?? TARGET_NAME_PROPERTY,\n },\n };\n });\n\n function ListPage() {\n const adapters = useAdapters();\n // Outside a `PendingTransactionsProvider` this watches nothing, so a host\n // that has not mounted one behaves exactly as it did before.\n const pending = usePendingTransactions();\n // Every internal href carries the locale. An unprefixed one still resolves —\n // the middleware redirects it — but the visitor pays a round trip per click.\n const withLocale = useLocaleHref();\n\n const queryClient = useQueryClient();\n const scope = entityQueryScope(entityConstructor);\n const metadataSource = metadataSourceOf(adapters);\n const affordances = useEntityAffordances(entityConstructor, metadataSource);\n const renderLink = useCrudRenderLink(catalogKey);\n\n // Records this browser has created but the service has not finished\n // writing. Prepended below rather than patched into the cache, which a\n // refetch would undo.\n const optimistic = pendingRecordsFor<TEntity>(pending, entityName);\n\n const pager = useDataLoading<TEntity, CrudContext>({\n uc: loadUCFactory<TEntity>(),\n ctx: mergeCrudContext(adapters, repository, pending),\n // Scoped rather than left to the per-instance fallback, which is correct\n // but unshared: with the entity's own scope one invalidation refreshes\n // every page and filter of it, which is what a bulk run needs — and it is\n // the same prefix `useReactiveInvalidation`already targets, so a generated\n // list now also refreshes on a reactive change event.\n queryKey: scope,\n ...(pageSize === undefined ? {} : { initialPageSize: pageSize }),\n });\n\n // Only when this caller may actually run something over a selection.\n //\n // Measured live: an `admin` holds `catalog-reference:*:read` and no\n // `retire`, so the service filters the verb out of `$metadata` — but the\n // selection column still rendered, offering a set that no action could be\n // taken on. A checkbox that can lead nowhere is worse than no checkbox: it\n // reads as a permission the user does not have.\n //\n // Decided from the served document rather than from the runner, because\n // the runner is a property of the *shell* (it knows the route) and the\n // grant is a property of the *caller*. The column therefore appears when\n // the document lands, which is the asynchrony serving affordances accepts\n // for every action surface.\n const canRunBulk =\n useCasesForSurface('bulk-bar', affordances.metadata?.useCases).length > 0;\n\n const bulk = useEntityBulk<TEntity>({\n run: canRunBulk ? runBulkUseCase : undefined,\n // A bulk write changed the rows underneath the listing, so the page is\n // re-read; the selection deliberately survives it (\"the selection\n // is still there afterwards\"), because the operator's next act is usually\n // to retry the failures or run a second verb on the same rows.\n onCompleted: () => {\n void queryClient.invalidateQueries({ queryKey: scope });\n },\n });\n\n // What a row's overflow menu runs, and the one place the list reports a\n // verb that failed.\n //\n // ⚠️ **A row verb and the form's are the same runner with different\n // arguments.** `EntityTable` hands back the whole record, because that is\n // what it has; `runUseCase` takes an id, because that is what a route needs\n // The list has no error slot of its own, so a failure lands in the\n // table's own error region — the same one a failed page read uses.\n const [useCaseError, setUseCaseError] = useState<\n TransitionalFailure | undefined\n >(undefined);\n // Called unconditionally and the *result* gated, the way the surfaces are\n // read in `EntityForm`: the name begins with `use`, so short-circuiting it\n // reads to `react-hooks/rules-of-hooks` as a conditional hook.\n const rowUseCases = useCasesForSurface(\n 'row-menu',\n affordances.metadata?.useCases,\n );\n const canRunRowUseCase = runUseCase !== undefined && rowUseCases.length > 0;\n\n const handleRowUseCase = canRunRowUseCase\n ? (key: string, item: TEntity) => {\n if (item.id == null) return;\n setUseCaseError(undefined);\n void runUseCase(key, item.id).then(\n () => queryClient.invalidateQueries({ queryKey: scope }),\n (failure: unknown) => setUseCaseError(asFailure(failure)),\n );\n }\n : undefined;\n\n return (\n <div className=\"flex flex-col gap-s\">\n {/* Above the table, because this is where `afterSave()` leaves the\n operator after a create — and a create is the only transactional\n write there is, so this is the surface the pending state has. */}\n <PendingNotice\n entries={pendingFor(pending, entityName)}\n onDismiss={pending.dismiss}\n />\n <EntityTable\n entityConstructor={entityConstructor}\n {...pager}\n items={[...optimistic, ...pager.items]}\n totalItems={pager.totalItems + optimistic.length}\n hrefFor={id => withLocale(`${basePath}/${String(id)}`)}\n newHref={withLocale(`${basePath}/${CATALOG_NEW_SLUG}`)}\n renderLink={renderLink}\n {...affordances}\n {...bulk.tableProps}\n onUseCase={handleRowUseCase}\n error={useCaseError ?? pager.error}\n >\n {columns}\n {toolbar}\n </EntityTable>\n </div>\n );\n }\n\n /**\n * Dual-host: as a route it reads its slug from the URL and returns to the list\n * on save; in a workspace tab it takes the slug and the post-save action as\n * props.\n */\n function SingleViewPage({\n slug,\n onSaved,\n onDeleted,\n draft,\n }: EntityCrudSingleViewProps = {}) {\n const adapters = useAdapters();\n const metadataSource = metadataSourceOf(adapters);\n const pending = usePendingTransactions();\n const router = useRouter();\n const withLocale = useLocaleHref();\n const params = useParams<{ slug: string }>();\n // A verb's failure is this page's, not the record fetch's or the mutation's,\n // so it needs a slot of its own to reach the form's error area.\n const [useCaseError, setUseCaseError] = useState<\n TransitionalFailure | undefined\n >(undefined);\n const id = slugToEntityId(slug ?? params.slug);\n\n const ctx = mergeCrudContext(adapters, repository, pending);\n\n const {\n entity,\n isLoading,\n error: loadError,\n reload,\n } = useEntityRecord<TEntity, CrudContext>({\n uc: getUCFactory<TEntity>(),\n ctx,\n id,\n });\n\n const {\n save,\n remove,\n isSaving,\n isDeleting,\n error: writeError,\n } = useEntityMutation<TEntity, CrudContext>({\n saveUc: saveUCFactory<TEntity>(),\n deleteUc: deleteUCFactory<TEntity>(),\n ctx,\n });\n\n // Against the record as loaded, not the draft: the rule is about which\n // record this is, not what the operator has typed into it.\n const linkFilters = useLinkFilters(linkPlans, entity);\n\n // Rebuilt inline on every render on purpose: `useEntityLinkSource` holds\n // these in a ref and keeps them out of its query keys, precisely so a caller\n // does not have to memoise them.\n const linkSources: EntityCrudLinkSource[] = linkPlans.map(\n (plan, index) => ({\n field: plan.field,\n descriptor: plan.descriptor,\n config: {\n entityConstructor: plan.entityConstructor,\n loadUc: loadUCFactory(),\n getUc: getUCFactory(),\n ctx: mergeCrudContext(adapters, plan.repository, pending),\n // The config's target is erased to `Entity`, and the host's filter to\n // `any`; the two meet here, where neither type is the real target's.\n baseFiltering: linkFilters[index] as FilterGroup<Entity> | undefined,\n },\n }),\n );\n\n // Inside a workspace tab, \"back to the list\" is the list's tab, not a URL\n // that would leave the workspace. A host's own callbacks still win.\n const host = useEntityNavHost();\n const renderLink = useCrudRenderLink(catalogKey);\n const queryClient = useQueryClient();\n // The list this page returns to has its page cached, and it is no longer\n // true. Invalidated rather than patched, for the reason `handOffWrite`\n // gives: a transactional create is not in the service yet, and the pending\n // set, not the cache, is what keeps it visible until it is.\n //\n // ⚠️ Invisible while every Open was a document load, which threw the cache\n // away anyway (#20): the first client-side navigation back to the list\n // showed the record as it was before the save.\n const refreshList = () =>\n queryClient.invalidateQueries({\n queryKey: entityQueryScope(entityConstructor),\n });\n const toList = () =>\n host === undefined\n ? router.push(withLocale(basePath))\n : host.toList(catalogKey);\n const afterSave = onSaved ?? toList;\n const afterDelete = onDeleted ?? toList;\n\n // The draft is spent once the write *commits*, and only here is that known:\n // `useEntityForm` neither fetches nor saves, so it cannot clear its own.\n // A failed mutation deliberately keeps the draft — the edit is still the\n // user's only copy of what they typed.\n const handleSave = async (next: TEntity) => {\n const saved = await save(next);\n if (saved === undefined) return;\n\n // ⚠️ Deliberately *not* a `setQueriesData` patch. The list refetches on\n // mount — precisely when the operator arrives, having just been navigated\n // here — and the server legitimately does not hold the record yet, so the\n // refetch would replace the patched page and the row would vanish a\n // moment after appearing. The pending set outlives refetches.\n handOffWrite({ id: String(saved.id), record: saved, pending, draft });\n void refreshList();\n afterSave();\n };\n\n const handleDelete = async () => {\n if (await remove(id)) {\n draft?.clear();\n void refreshList();\n afterDelete();\n }\n };\n\n /**\n * An `entity`-bound verb, on the record being shown.\n *\n * It **reloads** rather than patching what it has: the service decides the\n * resulting state — a `publish` may land on `published` from four different\n * places — and a client that assumed the new value would show the wrong one\n * the first time the rule changed. `id == null` means the create form,\n * where there is no record to act on and the form renders no verbs anyway.\n *\n * ⚠️ The rejection is **caught and shown**, never left to the promise.\n * `EntityForm`'s `onUseCase` returns `void`, so an uncaught rejection here\n * reaches the console and nothing else — measured on an offering refusing\n * `unpublish` from `draft`: the service answered `409` with a coded body,\n * the record correctly did not move, and the operator saw a button that\n * appeared to do nothing at all.\n */\n const handleUseCase =\n runUseCase === undefined || id == null\n ? undefined\n : (key: string) => {\n setUseCaseError(undefined);\n void runUseCase(key, id).then(\n () => reload(),\n (failure: unknown) => setUseCaseError(asFailure(failure)),\n );\n };\n\n return (\n <EntityCrudForm<TEntity>\n // Remounts (and reseeds the fields) once the record arrives.\n key={String(entity?.id ?? CATALOG_NEW_SLUG)}\n entityConstructor={entityConstructor}\n catalogKey={catalogKey}\n metadataSource={metadataSource}\n hiddenFields={hiddenFields}\n links={linkSources}\n entity={entity}\n isLoading={isLoading}\n isSaving={isSaving}\n isDeleting={isDeleting}\n error={loadError ?? writeError ?? useCaseError}\n onSave={handleSave}\n onDelete={id == null ? undefined : handleDelete}\n onUseCase={handleUseCase}\n backHref={withLocale(basePath)}\n renderLink={renderLink}\n draft={draft}\n />\n );\n }\n\n return {\n entityConstructor,\n entityKey: catalogKey,\n entityLabelKey,\n entityPluralKey,\n basePath,\n ListPage,\n SingleViewPage,\n };\n}\n"],"names":["deleteUCFactory","getUCFactory","loadUCFactory","saveUCFactory","describeEntityColumns","envelopeEntityName","extractMetaEntity","Internal","EntityTable","useCasesForSurface","entityQueryScope","useDataLoading","useEntityMutation","useEntityRecord","TransactionSinkTag","useQueryClient","Context","useParams","useRouter","useState","useLocaleHref","useEntityNavHost","pendingFor","pendingRecordsFor","usePendingTransactions","EntityCrudForm","handOffWrite","PendingNotice","CATALOG_NEW_SLUG","slugToEntityId","useCrudRenderLink","useEntityAffordances","useEntityBulk","TARGET_NAME_PROPERTY","mergeCrudContext","adapters","repository","sink","add","asFailure","failure","Error","useLinkFilters","links","record","map","link","useBaseFiltering","makeEntityCrud","entityConstructor","options","useAdapters","basePath","catalogKey","hiddenFields","columns","toolbar","pageSize","metadataSource","givenMetadataSource","metadata","runBulkUseCase","runUseCase","undefined","name","metadataSourceOf","meta","key","String","labelKey","entityLabelKey","pluralKey","entityPluralKey","entityName","descriptors","linkPlans","descriptor","find","entry","field","linkLabelProperty","labelProperty","linkSearchProperty","searchProperty","ListPage","affordances","pending","withLocale","queryClient","scope","renderLink","optimistic","pager","uc","ctx","queryKey","initialPageSize","canRunBulk","useCases","length","bulk","run","onCompleted","invalidateQueries","useCaseError","setUseCaseError","rowUseCases","canRunRowUseCase","handleRowUseCase","item","id","then","div","className","entries","onDismiss","dismiss","items","totalItems","hrefFor","newHref","tableProps","onUseCase","error","SingleViewPage","slug","onSaved","onDeleted","draft","loadError","router","params","entity","isLoading","reload","save","remove","isSaving","isDeleting","writeError","saveUc","deleteUc","linkFilters","linkSources","plan","index","config","loadUc","getUc","baseFiltering","host","refreshList","toList","push","afterSave","afterDelete","handleSave","next","saved","handleDelete","clear","handleUseCase","onSave","onDelete","backHref","entityKey"],"mappings":"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"}
|
|
@@ -74,7 +74,8 @@ registerFallbackCatalog('entity', {
|
|
|
74
74
|
*/ const errors = {
|
|
75
75
|
noActiveOrganization: 'Selecciona una organización para ver estos registros.',
|
|
76
76
|
alreadyRetired: 'El registro ya estaba retirado.',
|
|
77
|
-
unexpected: 'Ocurrió un error inesperado.'
|
|
77
|
+
unexpected: 'Ocurrió un error inesperado.',
|
|
78
|
+
internal: 'Algo falló de nuestro lado.'
|
|
78
79
|
};
|
|
79
80
|
registerFallbackCatalog('errors', {
|
|
80
81
|
es: errors,
|
|
@@ -1 +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\n * [ADR 0001](../../../../../../docs/adr/0001-the-tier-contract-and-the-host-seam.md)\n * calls a defect. 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 a host's copy would, 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;
|
|
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\n * [ADR 0001](../../../../../../docs/adr/0001-the-tier-contract-and-the-host-seam.md)\n * calls a defect. 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 a host's copy would, 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 internal: 'Algo falló de nuestro lado.',\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","internal"],"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;IACZC,UAAU;AACZ;AAEA1B,wBAAwB,UAAU;IAAEC,IAAIqB;IAAQD,IAAIC;AAAO"}
|
|
@@ -5,7 +5,7 @@ export declare const SEARCH_LIMIT_PARAM = "limit";
|
|
|
5
5
|
/**
|
|
6
6
|
* Below this, nothing is queried at all.
|
|
7
7
|
*
|
|
8
|
-
* A `like` becomes an unanchored
|
|
8
|
+
* A `like` becomes an unanchored `$regex` that ignores accents and case, which no index can
|
|
9
9
|
* serve — so this endpoint is a collection scan by construction. One character
|
|
10
10
|
* matches most of every collection and is worth nothing to the person typing, so
|
|
11
11
|
* the floor costs them nothing and saves four scans per keystroke.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"record-search-route.d.ts","sourceRoot":"","sources":["../../../src/lib/search/record-search-route.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"record-search-route.d.ts","sourceRoot":"","sources":["../../../src/lib/search/record-search-route.ts"],"names":[],"mappings":"AAkBA,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;AAE/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;AA8FD;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,uBAAuB,GAAI,8CAIrC,wBAAwB,MAGC,SAAS,OAAO,KAAG,OAAO,CAAC,QAAQ,CAuD9D,CAAC"}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
import { decodeProblem, InvalidQuery, ProtocolFailure, Timeout, Unauthenticated, UpstreamUnavailable } from '@entifix/core';
|
|
2
3
|
import { NextResponse } from 'next/server';
|
|
4
|
+
import { problemResponse } from '../config/problem-response.js';
|
|
3
5
|
import { bearerHeader, sessionToken } from '../session/bearer.js';
|
|
4
6
|
export const SEARCH_TERM_PARAM = 'q';
|
|
5
7
|
export const SEARCH_SOURCES_PARAM = 'sources';
|
|
@@ -7,7 +9,7 @@ export const SEARCH_LIMIT_PARAM = 'limit';
|
|
|
7
9
|
/**
|
|
8
10
|
* Below this, nothing is queried at all.
|
|
9
11
|
*
|
|
10
|
-
* A `like` becomes an unanchored
|
|
12
|
+
* A `like` becomes an unanchored `$regex` that ignores accents and case, which no index can
|
|
11
13
|
* serve — so this endpoint is a collection scan by construction. One character
|
|
12
14
|
* matches most of every collection and is worth nothing to the person typing, so
|
|
13
15
|
* the floor costs them nothing and saves four scans per keystroke.
|
|
@@ -15,19 +17,13 @@ export const SEARCH_LIMIT_PARAM = 'limit';
|
|
|
15
17
|
export const DEFAULT_GROUP_LIMIT = 5;
|
|
16
18
|
export const MAX_GROUP_LIMIT = 20;
|
|
17
19
|
export const DEFAULT_SOURCE_TIMEOUT_MS = 1500;
|
|
18
|
-
|
|
19
|
-
400: 'invalidQuery',
|
|
20
|
-
401: 'unauthenticated',
|
|
21
|
-
403: 'forbidden',
|
|
22
|
-
404: 'notFound',
|
|
23
|
-
409: 'noActiveOrganization'
|
|
24
|
-
};
|
|
25
|
-
const unavailable = (source, reason, status)=>({
|
|
20
|
+
const unavailable = (source, failure, status)=>({
|
|
26
21
|
ok: false,
|
|
27
22
|
unavailable: _extends({
|
|
28
23
|
source: source.key,
|
|
29
24
|
entity: source.entity,
|
|
30
|
-
reason
|
|
25
|
+
reason: failure.code,
|
|
26
|
+
category: failure.category
|
|
31
27
|
}, status === undefined ? {} : {
|
|
32
28
|
status
|
|
33
29
|
})
|
|
@@ -50,15 +46,24 @@ const unavailable = (source, reason, status)=>({
|
|
|
50
46
|
// `AbortSignal.timeout` rejects with a `DOMException` named `TimeoutError`;
|
|
51
47
|
// branching on the name rather than the class keeps this reachable from a
|
|
52
48
|
// test without fabricating a platform error.
|
|
53
|
-
return unavailable(source, error.name === 'TimeoutError' ?
|
|
49
|
+
return unavailable(source, error.name === 'TimeoutError' ? new Timeout({
|
|
50
|
+
dependency: source.key
|
|
51
|
+
}) : new UpstreamUnavailable({
|
|
52
|
+
upstream: source.key
|
|
53
|
+
}));
|
|
54
54
|
}
|
|
55
|
+
// The service says why, in a problem body; the code is the reason. A body
|
|
56
|
+
// that is not one decodes as `protocol` — still named, never silent.
|
|
55
57
|
if (!upstream.ok) {
|
|
56
|
-
|
|
57
|
-
return unavailable(source, (
|
|
58
|
+
const body = await upstream.json().catch(()=>undefined);
|
|
59
|
+
return unavailable(source, decodeProblem(body, upstream.status), upstream.status);
|
|
58
60
|
}
|
|
59
61
|
const body = await upstream.json().catch(()=>undefined);
|
|
60
62
|
const page = source.read(body);
|
|
61
|
-
return page === undefined ? unavailable(source,
|
|
63
|
+
return page === undefined ? unavailable(source, new ProtocolFailure({
|
|
64
|
+
reason: `${source.key} answered a page the source cannot read`,
|
|
65
|
+
upstreamStatus: upstream.status
|
|
66
|
+
}), upstream.status) : {
|
|
62
67
|
ok: true,
|
|
63
68
|
group: {
|
|
64
69
|
source: source.key,
|
|
@@ -101,12 +106,7 @@ const clampLimit = (raw, fallback)=>{
|
|
|
101
106
|
// and the endpoint becomes an anonymous amplifier against that service.
|
|
102
107
|
const token = await sessionToken();
|
|
103
108
|
if (token === undefined) {
|
|
104
|
-
return
|
|
105
|
-
error: 'unauthenticated',
|
|
106
|
-
code: 'unauthenticated'
|
|
107
|
-
}, {
|
|
108
|
-
status: 401
|
|
109
|
-
});
|
|
109
|
+
return problemResponse(new Unauthenticated());
|
|
110
110
|
}
|
|
111
111
|
const params = new URL(request.url).searchParams;
|
|
112
112
|
const asked = ((_params_get = params.get(SEARCH_SOURCES_PARAM)) != null ? _params_get : '').split(',').filter((name)=>name !== '');
|
|
@@ -115,13 +115,9 @@ const clampLimit = (raw, fallback)=>{
|
|
|
115
115
|
// Loud rather than ignored: no keystroke can produce this, only a caller
|
|
116
116
|
// naming a source that does not exist — and silently searching everything
|
|
117
117
|
// instead would answer a question nobody asked.
|
|
118
|
-
return
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
detail: `unknown search source "${unknown}"`
|
|
122
|
-
}, {
|
|
123
|
-
status: 400
|
|
124
|
-
});
|
|
118
|
+
return problemResponse(new InvalidQuery({
|
|
119
|
+
reason: `unknown search source "${unknown}"`
|
|
120
|
+
}));
|
|
125
121
|
}
|
|
126
122
|
const selected = asked.length === 0 ? sources : sources.filter((source)=>asked.includes(source.key));
|
|
127
123
|
const term = ((_params_get1 = params.get(SEARCH_TERM_PARAM)) != null ? _params_get1 : '').trim();
|
|
@@ -1 +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.\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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"}
|
|
1
|
+
{"version":3,"sources":["../../../src/lib/search/record-search-route.ts"],"sourcesContent":["import {\n decodeProblem,\n type EntifixFailure,\n InvalidQuery,\n ProtocolFailure,\n Timeout,\n Unauthenticated,\n UpstreamUnavailable,\n} from '@entifix/core';\nimport { NextResponse } from 'next/server';\n\nimport { problemResponse } from '../config/problem-response.js';\nimport { bearerHeader, sessionToken } from '../session/bearer.js';\nimport type {\n RecordSearchGroup,\n RecordSearchResponse,\n RecordSearchUnavailable,\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 `$regex` that ignores accents and case, 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\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 failure: EntifixFailure,\n status?: number,\n): SourceOutcome => ({\n ok: false,\n unavailable: {\n source: source.key,\n entity: source.entity,\n reason: failure.code,\n category: failure.category,\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 ? new Timeout({ dependency: source.key })\n : new UpstreamUnavailable({ upstream: source.key }),\n );\n }\n\n // The service says why, in a problem body; the code is the reason. A body\n // that is not one decodes as `protocol` — still named, never silent.\n if (!upstream.ok) {\n const body: unknown = await upstream.json().catch(() => undefined);\n return unavailable(\n source,\n decodeProblem(body, upstream.status),\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(\n source,\n new ProtocolFailure({\n reason: `${source.key} answered a page the source cannot read`,\n upstreamStatus: upstream.status,\n }),\n upstream.status,\n )\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.\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 problemResponse(new Unauthenticated());\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 problemResponse(\n new InvalidQuery({ reason: `unknown search source \"${unknown}\"` }),\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":["decodeProblem","InvalidQuery","ProtocolFailure","Timeout","Unauthenticated","UpstreamUnavailable","NextResponse","problemResponse","bearerHeader","sessionToken","SEARCH_TERM_PARAM","SEARCH_SOURCES_PARAM","SEARCH_LIMIT_PARAM","MIN_TERM_LENGTH","DEFAULT_GROUP_LIMIT","MAX_GROUP_LIMIT","DEFAULT_SOURCE_TIMEOUT_MS","unavailable","source","failure","status","ok","key","entity","reason","code","category","undefined","runSource","term","limit","token","timeoutMs","upstream","fetch","url","headers","cache","signal","AbortSignal","timeout","error","name","dependency","body","json","catch","page","read","upstreamStatus","group","labelKey","items","total","clampLimit","raw","fallback","asked","Number","isInteger","Math","min","createRecordSearchRoute","sources","defaultLimit","byKey","Map","map","GET","request","params","URL","searchParams","get","split","filter","unknown","find","has","selected","length","includes","trim","answer","groups","outcomes","Promise","all","flatMap","outcome"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"record-search-source.d.ts","sourceRoot":"","sources":["../../../src/lib/search/record-search-source.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"record-search-source.d.ts","sourceRoot":"","sources":["../../../src/lib/search/record-search-source.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,MAAM,EACX,KAAK,iBAAiB,EAMvB,MAAM,eAAe,CAAC;AAGvB,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAEnE;;;;;;;;;GASG;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;AAgBD;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,wBAAwB,GAAI,OAAO,SAAS,MAAM,EAAE,uGAS9D,wBAAwB,CAAC,OAAO,CAAC,KAAG,kBA2ItC,CAAC"}
|
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
-
import { describeEntityColumns,
|
|
2
|
+
import { describeEntityColumns, envelopeEntityName, readEntityPageEnvelope, serializeLoadRequestParams } from '@entifix/core';
|
|
3
3
|
import { Effect, Exit } from 'effect';
|
|
4
4
|
const descriptorOf = (columns, name)=>columns.find((column)=>column.name === name);
|
|
5
|
-
const rejection = (entityName, member, because, fix)=>new
|
|
6
|
-
entity: entityName,
|
|
7
|
-
member
|
|
8
|
-
});
|
|
5
|
+
const rejection = (entityName, member, because, fix)=>new Error(`Cannot search ${entityName} by "${member}": ${because} ${fix}`);
|
|
9
6
|
/**
|
|
10
7
|
* Declare a record search source, validating it against the entity's own
|
|
11
8
|
* metadata **now** rather than on the first keystroke.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/lib/search/record-search-source.ts"],"sourcesContent":["import {\n describeEntityColumns,\n EntifixLogicError,\n type Entity,\n type EntityConstructor,\n type EntityFieldDescriptor,\n type EntityLoadRequest,\n envelopeEntityName,\n readEntityPageEnvelope,\n serializeLoadRequestParams,\n} from '@entifix/core';\nimport { Effect, Exit } from 'effect';\n\nimport type { RecordSearchOption } from './record-search.types.js';\n\n/**\n * What a host declares in order to make one entity searchable from the palette.\n *\n * The search and label members are **declared**, not derived, and that is not an\n * oversight in the metadata: `linkSearchProperty`/`linkLabelProperty` are\n * properties of a *referring* accessor — they say how the owner of a relation\n * looks up its target — and an entity has no member that says \"this is what I am\n * called\". So the host that mounts the screens says it, next to the route those\n * screens live at.\n */\nexport interface RecordSearchSourceConfig<TEntity extends Entity> {\n /** Stable id, unique per host. Defaults to the entity's wire name. */\n readonly key?: string;\n readonly entityConstructor: EntityConstructor<TEntity>;\n /** Where the owning service listens, **without** the `/api` suffix. */\n readonly baseUrl: string;\n /** The `filterable` string member a term is matched against with `like`. */\n readonly searchProperty: string;\n /** The `sortable` member read as a record's label. */\n readonly labelProperty: string;\n /** An optional secondary line — a code, a role. */\n readonly sublabelProperty?: string;\n /** The group heading, as the entity's own `pluralKey`. */\n readonly labelKey: string;\n /** Where selecting a record lands. Locale-free; the caller prefixes it. */\n readonly href: (id: string) => string;\n}\n\n/** A declared source, reduced to what the fan-out actually calls. */\nexport interface RecordSearchSource {\n readonly key: string;\n readonly entity: string;\n readonly labelKey: string;\n /** The upstream URL for one term. */\n readonly url: (term: string, limit: number) => string;\n /** `undefined` when the body is not a readable `entityPage` envelope. */\n readonly read: (\n body: unknown,\n ) => { items: RecordSearchOption[]; total: number } | undefined;\n}\n\nconst descriptorOf = (\n columns: EntityFieldDescriptor[],\n name: string,\n): EntityFieldDescriptor | undefined =>\n columns.find(column => column.name === name);\n\nconst rejection = (\n entityName: string,\n member: string,\n because: string,\n fix: string,\n): EntifixLogicError =>\n new EntifixLogicError(\n `Cannot search ${entityName} by \"${member}\": ${because} ${fix}`,\n undefined,\n { entity: entityName, member },\n );\n\n/**\n * Declare a record search source, validating it against the entity's own\n * metadata **now** rather than on the first keystroke.\n *\n * Every check here guards a failure that is otherwise silent at both ends: the\n * service answers `400`, and a caller renders that as a group it could not\n * reach — which reads as \"there are no products\". `assertSearchable` took the\n * same posture for the relation picker, for the same reason.\n *\n * ⚠️ This throws at **module load**, so a bad declaration fails the whole app at\n * boot rather than just search. That is deliberate — a source silently missing\n * from a palette is indistinguishable from a permission the caller lacks — but\n * the blast radius is wider than the picker's, which fails only its own render.\n */\nexport const defineRecordSearchSource = <TEntity extends Entity>({\n key,\n entityConstructor,\n baseUrl,\n searchProperty,\n labelProperty,\n sublabelProperty,\n labelKey,\n href,\n}: RecordSearchSourceConfig<TEntity>): RecordSearchSource => {\n const entity = envelopeEntityName(entityConstructor);\n const entityName = entityConstructor.name;\n const columns = describeEntityColumns(entityConstructor);\n\n const search = descriptorOf(columns, searchProperty);\n if (search === undefined) {\n throw rejection(\n entityName,\n searchProperty,\n 'the entity declares no such member.',\n 'Name a member the entity actually has.',\n );\n }\n // `filterable` is simultaneously the server-side RSQL allowlist.\n if (!search.filterable) {\n throw rejection(\n entityName,\n searchProperty,\n 'the member is not filterable, so the service would reject the query.',\n 'Declare it `filterable` on the entity, or search another member.',\n );\n }\n // A `like` against anything else reaches `coerceValue`, which rejects a\n // partial term — so an enum member reads as declarable and is permanently\n // broken: every keystroke short of a whole value answers `400`.\n if (search.type !== 'string') {\n throw rejection(\n entityName,\n searchProperty,\n `the member is a ${search.type}, and a partial term is not a valid value for it.`,\n 'Search a string member instead.',\n );\n }\n\n const label = descriptorOf(columns, labelProperty);\n if (label === undefined) {\n throw rejection(\n entityName,\n labelProperty,\n 'the entity declares no such label member.',\n 'Name a member the entity actually has.',\n );\n }\n // The group is sorted by the label, so the flag is load-bearing: without it\n // the service rejects the sort and the group degrades whole. Unsorted was the\n // alternative and is worse — the services apply no default order, so results\n // would arrive in storage order and shuffle as records are rewritten.\n // A localized label sorts one locale at a time, and a search that fans out\n // across services holds none: the bare member would answer `400`.\n if (label.type === 'localized') {\n throw rejection(\n entityName,\n labelProperty,\n 'the label member is localized, and the search has no locale to sort it in.',\n 'Label records by a string member.',\n );\n }\n if (!label.sortable) {\n throw rejection(\n entityName,\n labelProperty,\n 'the label member is not sortable, so the service would reject the sort.',\n 'Declare it `sortable` on the entity, or label records by another member.',\n );\n }\n\n let sublabel: EntityFieldDescriptor | undefined;\n if (sublabelProperty !== undefined) {\n sublabel = descriptorOf(columns, sublabelProperty);\n if (sublabel === undefined) {\n throw rejection(\n entityName,\n sublabelProperty,\n 'the entity declares no such sublabel member.',\n 'Name a member the entity actually has, or drop `sublabelProperty`.',\n );\n }\n }\n\n const read = (\n body: unknown,\n ): { items: RecordSearchOption[]; total: number } | undefined => {\n const page = Effect.runSyncExit(\n readEntityPageEnvelope(entityConstructor, body),\n );\n if (Exit.isFailure(page)) return undefined;\n\n const items = page.value.items\n .filter(record => (record as Entity).id != null)\n .map(record => {\n const row = record as unknown as Record<string, unknown>;\n const raw = row[labelProperty];\n const id = String((record as Entity).id);\n return {\n id,\n // A blank label is as useless as a missing one, so both fall back to\n // the id — an unnamed record must stay distinguishable from the one\n // above it. Same rule the relation picker's `labelOf` applies.\n label: raw == null || raw === '' ? id : String(raw),\n ...(sublabel !== undefined && row[sublabel.name] != null\n ? { sublabel: String(row[sublabel.name]) }\n : {}),\n entity,\n href: href(id),\n } satisfies RecordSearchOption;\n });\n\n return { items, total: page.value.total };\n };\n\n return {\n key: key ?? entity,\n entity,\n labelKey,\n /**\n * Built through `serializeLoadRequestParams`, never by hand. That is\n * correctness rather than tidiness: `encodeRsqlValue` quotes a term carrying\n * any of `;,()'\"=!<>\\` or whitespace, so `Acme, Inc.` stays one value\n * instead of splitting the expression into two comparisons.\n */\n url: (term, limit) => {\n const request = {\n filtering: [\n {\n property: search.key,\n operator: 'like' as const,\n value: term,\n },\n ],\n sorting: [{ 0: { property: label.key, type: 'asc' as const } }],\n page: 1,\n pageSize: limit,\n } as unknown as EntityLoadRequest;\n\n return `${baseUrl}/api/${entity}?${serializeLoadRequestParams(request).toString()}`;\n },\n read,\n };\n};\n"],"names":["describeEntityColumns","EntifixLogicError","envelopeEntityName","readEntityPageEnvelope","serializeLoadRequestParams","Effect","Exit","descriptorOf","columns","name","find","column","rejection","entityName","member","because","fix","undefined","entity","defineRecordSearchSource","key","entityConstructor","baseUrl","searchProperty","labelProperty","sublabelProperty","labelKey","href","search","filterable","type","label","sortable","sublabel","read","body","page","runSyncExit","isFailure","items","value","filter","record","id","map","row","raw","String","total","url","term","limit","request","filtering","property","operator","sorting","pageSize","toString"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../src/lib/search/record-search-source.ts"],"sourcesContent":["import {\n describeEntityColumns,\n type Entity,\n type EntityConstructor,\n type EntityFieldDescriptor,\n type EntityLoadRequest,\n envelopeEntityName,\n readEntityPageEnvelope,\n serializeLoadRequestParams,\n} from '@entifix/core';\nimport { Effect, Exit } from 'effect';\n\nimport type { RecordSearchOption } from './record-search.types.js';\n\n/**\n * What a host declares in order to make one entity searchable from the palette.\n *\n * The search and label members are **declared**, not derived, and that is not an\n * oversight in the metadata: `linkSearchProperty`/`linkLabelProperty` are\n * properties of a *referring* accessor — they say how the owner of a relation\n * looks up its target — and an entity has no member that says \"this is what I am\n * called\". So the host that mounts the screens says it, next to the route those\n * screens live at.\n */\nexport interface RecordSearchSourceConfig<TEntity extends Entity> {\n /** Stable id, unique per host. Defaults to the entity's wire name. */\n readonly key?: string;\n readonly entityConstructor: EntityConstructor<TEntity>;\n /** Where the owning service listens, **without** the `/api` suffix. */\n readonly baseUrl: string;\n /** The `filterable` string member a term is matched against with `like`. */\n readonly searchProperty: string;\n /** The `sortable` member read as a record's label. */\n readonly labelProperty: string;\n /** An optional secondary line — a code, a role. */\n readonly sublabelProperty?: string;\n /** The group heading, as the entity's own `pluralKey`. */\n readonly labelKey: string;\n /** Where selecting a record lands. Locale-free; the caller prefixes it. */\n readonly href: (id: string) => string;\n}\n\n/** A declared source, reduced to what the fan-out actually calls. */\nexport interface RecordSearchSource {\n readonly key: string;\n readonly entity: string;\n readonly labelKey: string;\n /** The upstream URL for one term. */\n readonly url: (term: string, limit: number) => string;\n /** `undefined` when the body is not a readable `entityPage` envelope. */\n readonly read: (\n body: unknown,\n ) => { items: RecordSearchOption[]; total: number } | undefined;\n}\n\nconst descriptorOf = (\n columns: EntityFieldDescriptor[],\n name: string,\n): EntityFieldDescriptor | undefined =>\n columns.find(column => column.name === name);\n\nconst rejection = (\n entityName: string,\n member: string,\n because: string,\n fix: string,\n): Error =>\n new Error(`Cannot search ${entityName} by \"${member}\": ${because} ${fix}`);\n\n/**\n * Declare a record search source, validating it against the entity's own\n * metadata **now** rather than on the first keystroke.\n *\n * Every check here guards a failure that is otherwise silent at both ends: the\n * service answers `400`, and a caller renders that as a group it could not\n * reach — which reads as \"there are no products\". `assertSearchable` took the\n * same posture for the relation picker, for the same reason.\n *\n * ⚠️ This throws at **module load**, so a bad declaration fails the whole app at\n * boot rather than just search. That is deliberate — a source silently missing\n * from a palette is indistinguishable from a permission the caller lacks — but\n * the blast radius is wider than the picker's, which fails only its own render.\n */\nexport const defineRecordSearchSource = <TEntity extends Entity>({\n key,\n entityConstructor,\n baseUrl,\n searchProperty,\n labelProperty,\n sublabelProperty,\n labelKey,\n href,\n}: RecordSearchSourceConfig<TEntity>): RecordSearchSource => {\n const entity = envelopeEntityName(entityConstructor);\n const entityName = entityConstructor.name;\n const columns = describeEntityColumns(entityConstructor);\n\n const search = descriptorOf(columns, searchProperty);\n if (search === undefined) {\n throw rejection(\n entityName,\n searchProperty,\n 'the entity declares no such member.',\n 'Name a member the entity actually has.',\n );\n }\n // `filterable` is simultaneously the server-side RSQL allowlist.\n if (!search.filterable) {\n throw rejection(\n entityName,\n searchProperty,\n 'the member is not filterable, so the service would reject the query.',\n 'Declare it `filterable` on the entity, or search another member.',\n );\n }\n // A `like` against anything else reaches `coerceValue`, which rejects a\n // partial term — so an enum member reads as declarable and is permanently\n // broken: every keystroke short of a whole value answers `400`.\n if (search.type !== 'string') {\n throw rejection(\n entityName,\n searchProperty,\n `the member is a ${search.type}, and a partial term is not a valid value for it.`,\n 'Search a string member instead.',\n );\n }\n\n const label = descriptorOf(columns, labelProperty);\n if (label === undefined) {\n throw rejection(\n entityName,\n labelProperty,\n 'the entity declares no such label member.',\n 'Name a member the entity actually has.',\n );\n }\n // The group is sorted by the label, so the flag is load-bearing: without it\n // the service rejects the sort and the group degrades whole. Unsorted was the\n // alternative and is worse — the services apply no default order, so results\n // would arrive in storage order and shuffle as records are rewritten.\n // A localized label sorts one locale at a time, and a search that fans out\n // across services holds none: the bare member would answer `400`.\n if (label.type === 'localized') {\n throw rejection(\n entityName,\n labelProperty,\n 'the label member is localized, and the search has no locale to sort it in.',\n 'Label records by a string member.',\n );\n }\n if (!label.sortable) {\n throw rejection(\n entityName,\n labelProperty,\n 'the label member is not sortable, so the service would reject the sort.',\n 'Declare it `sortable` on the entity, or label records by another member.',\n );\n }\n\n let sublabel: EntityFieldDescriptor | undefined;\n if (sublabelProperty !== undefined) {\n sublabel = descriptorOf(columns, sublabelProperty);\n if (sublabel === undefined) {\n throw rejection(\n entityName,\n sublabelProperty,\n 'the entity declares no such sublabel member.',\n 'Name a member the entity actually has, or drop `sublabelProperty`.',\n );\n }\n }\n\n const read = (\n body: unknown,\n ): { items: RecordSearchOption[]; total: number } | undefined => {\n const page = Effect.runSyncExit(\n readEntityPageEnvelope(entityConstructor, body),\n );\n if (Exit.isFailure(page)) return undefined;\n\n const items = page.value.items\n .filter(record => (record as Entity).id != null)\n .map(record => {\n const row = record as unknown as Record<string, unknown>;\n const raw = row[labelProperty];\n const id = String((record as Entity).id);\n return {\n id,\n // A blank label is as useless as a missing one, so both fall back to\n // the id — an unnamed record must stay distinguishable from the one\n // above it. Same rule the relation picker's `labelOf` applies.\n label: raw == null || raw === '' ? id : String(raw),\n ...(sublabel !== undefined && row[sublabel.name] != null\n ? { sublabel: String(row[sublabel.name]) }\n : {}),\n entity,\n href: href(id),\n } satisfies RecordSearchOption;\n });\n\n return { items, total: page.value.total };\n };\n\n return {\n key: key ?? entity,\n entity,\n labelKey,\n /**\n * Built through `serializeLoadRequestParams`, never by hand. That is\n * correctness rather than tidiness: `encodeRsqlValue` quotes a term carrying\n * any of `;,()'\"=!<>\\` or whitespace, so `Acme, Inc.` stays one value\n * instead of splitting the expression into two comparisons.\n */\n url: (term, limit) => {\n const request = {\n filtering: [\n {\n property: search.key,\n operator: 'like' as const,\n value: term,\n },\n ],\n sorting: [{ 0: { property: label.key, type: 'asc' as const } }],\n page: 1,\n pageSize: limit,\n } as unknown as EntityLoadRequest;\n\n return `${baseUrl}/api/${entity}?${serializeLoadRequestParams(request).toString()}`;\n },\n read,\n };\n};\n"],"names":["describeEntityColumns","envelopeEntityName","readEntityPageEnvelope","serializeLoadRequestParams","Effect","Exit","descriptorOf","columns","name","find","column","rejection","entityName","member","because","fix","Error","defineRecordSearchSource","key","entityConstructor","baseUrl","searchProperty","labelProperty","sublabelProperty","labelKey","href","entity","search","undefined","filterable","type","label","sortable","sublabel","read","body","page","runSyncExit","isFailure","items","value","filter","record","id","map","row","raw","String","total","url","term","limit","request","filtering","property","operator","sorting","pageSize","toString"],"mappings":"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"}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { FailureCategory } from '@entifix/core';
|
|
1
2
|
/**
|
|
2
3
|
* The wire contract of `GET /api/search` — the cross-domain record search the
|
|
3
4
|
* command palette reads.
|
|
@@ -19,7 +20,14 @@
|
|
|
19
20
|
* Every member is a key in the shared `errors` catalog, so a caller renders one
|
|
20
21
|
* through the documented runtime-key hatch rather than inventing copy.
|
|
21
22
|
*/
|
|
22
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Why a source produced nothing: the code of the failure it answered with,
|
|
25
|
+
* decoded from its problem body — `noActiveOrganization`, `forbidden`,
|
|
26
|
+
* `upstreamUnavailable` — or `timeout` when it never answered in time. A
|
|
27
|
+
* code, so the browser renders it through the `errors` catalog like any other
|
|
28
|
+
* refusal.
|
|
29
|
+
*/
|
|
30
|
+
export type RecordSearchUnavailableReason = string;
|
|
23
31
|
/** One record a caller can show and route to. */
|
|
24
32
|
export interface RecordSearchOption {
|
|
25
33
|
readonly id: string;
|
|
@@ -66,6 +74,8 @@ export interface RecordSearchUnavailable {
|
|
|
66
74
|
readonly source: string;
|
|
67
75
|
readonly entity: string;
|
|
68
76
|
readonly reason: RecordSearchUnavailableReason;
|
|
77
|
+
/** The failure's category: what decides whether this is scope or an outage. */
|
|
78
|
+
readonly category: FailureCategory;
|
|
69
79
|
/** The upstream status, when there was a response. Absent on a throw. */
|
|
70
80
|
readonly status?: number;
|
|
71
81
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"record-search.types.d.ts","sourceRoot":"","sources":["../../../src/lib/search/record-search.types.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH;;;;;;;;;;;;GAYG;AACH,MAAM,MAAM,6BAA6B,
|
|
1
|
+
{"version":3,"file":"record-search.types.d.ts","sourceRoot":"","sources":["../../../src/lib/search/record-search.types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAErD;;;;;;;GAOG;AAEH;;;;;;;;;;;;GAYG;AACH;;;;;;GAMG;AACH,MAAM,MAAM,6BAA6B,GAAG,MAAM,CAAC;AAEnD,iDAAiD;AACjD,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,+EAA+E;IAC/E,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B;;;;;;;OAOG;IACH,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAED,2BAA2B;AAC3B,MAAM,WAAW,iBAAiB;IAChC,oEAAoE;IACpE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,KAAK,EAAE,SAAS,kBAAkB,EAAE,CAAC;IAC9C;;;;;OAKG;IACH,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;AAED,iDAAiD;AACjD,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,6BAA6B,CAAC;IAC/C,+EAA+E;IAC/E,QAAQ,CAAC,QAAQ,EAAE,eAAe,CAAC;IACnC,yEAAyE;IACzE,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,EAAE,SAAS,iBAAiB,EAAE,CAAC;IAC9C,QAAQ,CAAC,WAAW,EAAE,SAAS,uBAAuB,EAAE,CAAC;CAC1D"}
|
|
@@ -1,23 +1,4 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The wire contract of `GET /api/search` — the cross-domain record search the
|
|
3
|
-
* command palette reads.
|
|
4
|
-
*
|
|
5
|
-
* Types only, so both halves can name them: the route handler builds these on
|
|
6
|
-
* the server, and the browser's `searchRecords` returns them. A type crosses the
|
|
7
|
-
* client/server entry split freely because it erases.
|
|
8
|
-
*/ /**
|
|
9
|
-
* Why a source produced no group.
|
|
10
|
-
*
|
|
11
|
-
* The split is the point, and it is not cosmetic. `forbidden` and
|
|
12
|
-
* `noActiveOrganization` are the **normal** state for some callers on some
|
|
13
|
-
* sources — an operator holds no membership, so every tenant-plane search
|
|
14
|
-
* answers `409` — and rendering "we could not reach this" for the ordinary case
|
|
15
|
-
* teaches an operator to ignore the warning. `timeout`/`network`/`unexpected`
|
|
16
|
-
* are the ones that mean something is actually wrong.
|
|
17
|
-
*
|
|
18
|
-
* Every member is a key in the shared `errors` catalog, so a caller renders one
|
|
19
|
-
* through the documented runtime-key hatch rather than inventing copy.
|
|
20
|
-
*/ /**
|
|
21
2
|
* The whole answer.
|
|
22
3
|
*
|
|
23
4
|
* A degraded source is **named**, never a silently missing group. An empty group
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/lib/search/record-search.types.ts"],"sourcesContent":["/**\n * The wire contract of `GET /api/search` — the cross-domain record search the\n * command palette reads.\n *\n * Types only, so both halves can name them: the route handler builds these on\n * the server, and the browser's `searchRecords` returns them. A type crosses the\n * client/server entry split freely because it erases.\n */\n\n/**\n * Why a source produced no group.\n *\n * The split is the point, and it is not cosmetic. `forbidden` and\n * `noActiveOrganization` are the **normal** state for some callers on some\n * sources — an operator holds no membership, so every tenant-plane search\n * answers `409` — and rendering \"we could not reach this\" for the ordinary case\n * teaches an operator to ignore the warning. `timeout`/`network`/`unexpected`\n * are the ones that mean something is actually wrong.\n *\n * Every member is a key in the shared `errors` catalog, so a caller renders one\n * through the documented runtime-key hatch rather than inventing copy.\n */\
|
|
1
|
+
{"version":3,"sources":["../../../src/lib/search/record-search.types.ts"],"sourcesContent":["import type { FailureCategory } from '@entifix/core';\n\n/**\n * The wire contract of `GET /api/search` — the cross-domain record search the\n * command palette reads.\n *\n * Types only, so both halves can name them: the route handler builds these on\n * the server, and the browser's `searchRecords` returns them. A type crosses the\n * client/server entry split freely because it erases.\n */\n\n/**\n * Why a source produced no group.\n *\n * The split is the point, and it is not cosmetic. `forbidden` and\n * `noActiveOrganization` are the **normal** state for some callers on some\n * sources — an operator holds no membership, so every tenant-plane search\n * answers `409` — and rendering \"we could not reach this\" for the ordinary case\n * teaches an operator to ignore the warning. `timeout`/`network`/`unexpected`\n * are the ones that mean something is actually wrong.\n *\n * Every member is a key in the shared `errors` catalog, so a caller renders one\n * through the documented runtime-key hatch rather than inventing copy.\n */\n/**\n * Why a source produced nothing: the code of the failure it answered with,\n * decoded from its problem body — `noActiveOrganization`, `forbidden`,\n * `upstreamUnavailable` — or `timeout` when it never answered in time. A\n * code, so the browser renders it through the `errors` catalog like any other\n * refusal.\n */\nexport type RecordSearchUnavailableReason = string;\n\n/** One record a caller can show and route to. */\nexport interface RecordSearchOption {\n readonly id: string;\n readonly label: string;\n /** A secondary line — a code, a role. Absent when the source declared none. */\n readonly sublabel?: string;\n /**\n * `envelopeEntityName(Ctor)` — `key ?? name`.\n *\n * Repeated on every option rather than left to the group, because an option is\n * routed on its own once a caller has flattened the list: `['entity', entity]`\n * is exactly `entityQueryScope`'s output, and `entity:<entity>:<id>` is the\n * workspace tab address.\n */\n readonly entity: string;\n /**\n * Where selecting this record lands, **without** a locale prefix — the caller\n * adds one, the same way every other internal href in the back office works.\n */\n readonly href: string;\n}\n\n/** One source's answer. */\nexport interface RecordSearchGroup {\n /** The source's declared key. Stable, and the caller's ordering. */\n readonly source: string;\n readonly entity: string;\n /**\n * The heading, as the entity's own `pluralKey`. A key rather than copy,\n * because the server has no locale — and the entity catalogs already carry one\n * for every source, so record search introduces no copy of its own.\n */\n readonly labelKey: string;\n readonly items: readonly RecordSearchOption[];\n /**\n * Matches **before** the per-group limit, so \"3 more\" is renderable. A\n * reachable source with no matches is a present group with an empty `items`,\n * never an omission — that distinction is the whole reason\n * {@link RecordSearchResponse.unavailable} exists.\n */\n readonly total: number;\n}\n\n/** One source that produced nothing, and why. */\nexport interface RecordSearchUnavailable {\n readonly source: string;\n readonly entity: string;\n readonly reason: RecordSearchUnavailableReason;\n /** The failure's category: what decides whether this is scope or an outage. */\n readonly category: FailureCategory;\n /** The upstream status, when there was a response. Absent on a throw. */\n readonly status?: number;\n}\n\n/**\n * The whole answer.\n *\n * A degraded source is **named**, never a silently missing group. An empty group\n * is a confident claim that there are no matching records, and a search that\n * confidently reports nothing when it in fact could not look is the failure\n * mode this shape exists to prevent.\n */\nexport interface RecordSearchResponse {\n readonly term: string;\n readonly groups: readonly RecordSearchGroup[];\n readonly unavailable: readonly RecordSearchUnavailable[];\n}\n"],"names":[],"mappings":"AAuFA;;;;;;;CAOC,GACD,WAIC"}
|
package/dist/server.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export * from './lib/health/health-routes.js';
|
|
2
2
|
export * from './lib/config/config-route.js';
|
|
3
3
|
export * from './lib/config/configuration-source.js';
|
|
4
|
+
export * from './lib/config/problem-response.js';
|
|
4
5
|
export * from './lib/config/service-proxy-route.js';
|
|
5
6
|
export * from './lib/session/cookies.js';
|
|
6
7
|
export * from './lib/session/refresh-route.js';
|
package/dist/server.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAWA,cAAc,+BAA+B,CAAC;AAG9C,cAAc,8BAA8B,CAAC;AAM7C,cAAc,sCAAsC,CAAC;
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAWA,cAAc,+BAA+B,CAAC;AAG9C,cAAc,8BAA8B,CAAC;AAM7C,cAAc,sCAAsC,CAAC;AACrD,cAAc,kCAAkC,CAAC;AAGjD,cAAc,qCAAqC,CAAC;AAGpD,cAAc,0BAA0B,CAAC;AACzC,cAAc,gCAAgC,CAAC;AAK/C,cAAc,oBAAoB,CAAC;AAInC,cAAc,qCAAqC,CAAC;AACpD,cAAc,sCAAsC,CAAC;AAKrD,cAAc,yBAAyB,CAAC;AAIxC,cAAc,gCAAgC,CAAC;AAG/C,cAAc,yBAAyB,CAAC"}
|
package/dist/server.js
CHANGED
|
@@ -15,6 +15,7 @@ export * from './lib/config/config-route.js';
|
|
|
15
15
|
// through its own app's `/api/config` route — has to carry it itself. It never
|
|
16
16
|
// reaches the browser: this is the server entry.
|
|
17
17
|
export * from './lib/config/configuration-source.js';
|
|
18
|
+
export * from './lib/config/problem-response.js';
|
|
18
19
|
// The same-origin proxy each host mounts per backend it talks to.
|
|
19
20
|
export * from './lib/config/service-proxy-route.js';
|
|
20
21
|
// Session cookies + the shared refresh route handler
|
package/dist/server.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/server.ts"],"sourcesContent":["// Server-only surface, published as `@entifix/next-shell/server`.\n//\n// It stays a separate entry so route handlers and cookie helpers are never\n// reached through the client surface: stamped as client modules they would\n// become client references and their `next/server` imports would fail. The\n// library compiles per-file, so each module keeps its own `\"use client\"` (or\n// keeps none, as here) instead of inheriting a bundle-wide banner.\n\n// React server components\n\n// Probe route handlers (liveness/readiness) shared by every Next app.\nexport * from './lib/health/health-routes.js';\n\n// The `GET /api/config` handler + the same-origin domain rewrite it applies.\nexport * from './lib/config/config-route.js';\n\n// The shared fleet token, and the header config-service reads it from. Exported\n// because a server component that reads config-service directly — rather than\n// through its own app's `/api/config` route — has to carry it itself. It never\n// reaches the browser: this is the server entry.\nexport * from './lib/config/configuration-source.js';\n\n// The same-origin proxy each host mounts per backend it talks to.\nexport * from './lib/config/service-proxy-route.js';\n\n// Session cookies + the shared refresh route handler\nexport * from './lib/session/cookies.js';\nexport * from './lib/session/refresh-route.js';\n\n// The `[slug]` value that means \"create\". A pure constant, exported from both\n// entries because both need it: the generated pages read it in the browser, and\n// a shell's palette commands compose the create route with it on the server.\nexport * from './lib/crud/slug.js';\n\n// The cross-domain record search: the aggregating route, and the declarations a\n// host fans out over.\nexport * from './lib/search/record-search-route.js';\nexport * from './lib/search/record-search-source.js';\n\n// The access cookie, read once and forwarded as a bearer. Three server surfaces\n// carry it the same way — the per-backend proxy, the auth shell's hand-written\n// handlers, and the record search fan-out — and none of them grants anything.\nexport * from './lib/session/bearer.js';\n\n// Account-link builders. Pure functions, but server layouts call them directly,\n// and anything exported from the client entry becomes a client function.\nexport * from './lib/session/account-links.js';\n\n// Device identity — read from the request, written onto the response.\nexport * from './lib/session/device.js';\n"],"names":[],"mappings":"AAAA,kEAAkE;AAClE,EAAE;AACF,2EAA2E;AAC3E,2EAA2E;AAC3E,2EAA2E;AAC3E,6EAA6E;AAC7E,mEAAmE;AAEnE,0BAA0B;AAE1B,sEAAsE;AACtE,cAAc,gCAAgC;AAE9C,6EAA6E;AAC7E,cAAc,+BAA+B;AAE7C,gFAAgF;AAChF,8EAA8E;AAC9E,+EAA+E;AAC/E,iDAAiD;AACjD,cAAc,uCAAuC;
|
|
1
|
+
{"version":3,"sources":["../src/server.ts"],"sourcesContent":["// Server-only surface, published as `@entifix/next-shell/server`.\n//\n// It stays a separate entry so route handlers and cookie helpers are never\n// reached through the client surface: stamped as client modules they would\n// become client references and their `next/server` imports would fail. The\n// library compiles per-file, so each module keeps its own `\"use client\"` (or\n// keeps none, as here) instead of inheriting a bundle-wide banner.\n\n// React server components\n\n// Probe route handlers (liveness/readiness) shared by every Next app.\nexport * from './lib/health/health-routes.js';\n\n// The `GET /api/config` handler + the same-origin domain rewrite it applies.\nexport * from './lib/config/config-route.js';\n\n// The shared fleet token, and the header config-service reads it from. Exported\n// because a server component that reads config-service directly — rather than\n// through its own app's `/api/config` route — has to carry it itself. It never\n// reaches the browser: this is the server entry.\nexport * from './lib/config/configuration-source.js';\nexport * from './lib/config/problem-response.js';\n\n// The same-origin proxy each host mounts per backend it talks to.\nexport * from './lib/config/service-proxy-route.js';\n\n// Session cookies + the shared refresh route handler\nexport * from './lib/session/cookies.js';\nexport * from './lib/session/refresh-route.js';\n\n// The `[slug]` value that means \"create\". A pure constant, exported from both\n// entries because both need it: the generated pages read it in the browser, and\n// a shell's palette commands compose the create route with it on the server.\nexport * from './lib/crud/slug.js';\n\n// The cross-domain record search: the aggregating route, and the declarations a\n// host fans out over.\nexport * from './lib/search/record-search-route.js';\nexport * from './lib/search/record-search-source.js';\n\n// The access cookie, read once and forwarded as a bearer. Three server surfaces\n// carry it the same way — the per-backend proxy, the auth shell's hand-written\n// handlers, and the record search fan-out — and none of them grants anything.\nexport * from './lib/session/bearer.js';\n\n// Account-link builders. Pure functions, but server layouts call them directly,\n// and anything exported from the client entry becomes a client function.\nexport * from './lib/session/account-links.js';\n\n// Device identity — read from the request, written onto the response.\nexport * from './lib/session/device.js';\n"],"names":[],"mappings":"AAAA,kEAAkE;AAClE,EAAE;AACF,2EAA2E;AAC3E,2EAA2E;AAC3E,2EAA2E;AAC3E,6EAA6E;AAC7E,mEAAmE;AAEnE,0BAA0B;AAE1B,sEAAsE;AACtE,cAAc,gCAAgC;AAE9C,6EAA6E;AAC7E,cAAc,+BAA+B;AAE7C,gFAAgF;AAChF,8EAA8E;AAC9E,+EAA+E;AAC/E,iDAAiD;AACjD,cAAc,uCAAuC;AACrD,cAAc,mCAAmC;AAEjD,kEAAkE;AAClE,cAAc,sCAAsC;AAEpD,qDAAqD;AACrD,cAAc,2BAA2B;AACzC,cAAc,iCAAiC;AAE/C,8EAA8E;AAC9E,gFAAgF;AAChF,6EAA6E;AAC7E,cAAc,qBAAqB;AAEnC,gFAAgF;AAChF,sBAAsB;AACtB,cAAc,sCAAsC;AACpD,cAAc,uCAAuC;AAErD,gFAAgF;AAChF,+EAA+E;AAC/E,8EAA8E;AAC9E,cAAc,0BAA0B;AAExC,gFAAgF;AAChF,yEAAyE;AACzE,cAAc,iCAAiC;AAE/C,sEAAsE;AACtE,cAAc,0BAA0B"}
|