@@ -0,0 +1,70 @@
|
||||
// What the catalog module does, as pure TypeScript over the port in ports.ts.
|
||||
//
|
||||
// Products are the organization's: every project of the app sees the same ones (a multi-tenant app keeps
|
||||
// all its tenants' products in its deployment's one organization; partitioning by app tenant is an open
|
||||
// owner decision, B-wave contract §11.4).
|
||||
|
||||
import type { ProjectScope } from "@conusai-kit/core"
|
||||
import {
|
||||
type ImportOutcomeView,
|
||||
type ImportRow,
|
||||
type ImportView,
|
||||
PAGE_LIMIT,
|
||||
type ProductSearchField,
|
||||
type ProductView,
|
||||
} from "../contract/index.js"
|
||||
import type { CatalogReads, CatalogService, Page } from "./ports.js"
|
||||
|
||||
/** The service's idempotency key of a request: one project's request id never answers another's. */
|
||||
export const requestKey = (scope: Pick<ProjectScope, "projectId">, requestId: string): string =>
|
||||
`app-project:${scope.projectId}:${requestId}`
|
||||
|
||||
const cursor = (after: string | undefined) => (after === undefined ? {} : { after })
|
||||
|
||||
export function listProducts(
|
||||
reads: CatalogReads,
|
||||
query: { after?: string; limit?: number },
|
||||
): Promise<Page<ProductView>> {
|
||||
return reads.listProducts({ ...cursor(query.after), limit: query.limit ?? PAGE_LIMIT })
|
||||
}
|
||||
|
||||
export function searchProducts(
|
||||
reads: CatalogReads,
|
||||
query: { q: string; by?: ProductSearchField; after?: string; limit?: number },
|
||||
): Promise<Page<ProductView>> {
|
||||
const q = query.q.trim()
|
||||
const by = query.by ?? "name"
|
||||
const limit = query.limit ?? PAGE_LIMIT
|
||||
if (by === "name") return reads.searchProducts({ q, ...cursor(query.after), limit })
|
||||
return reads.listProducts({ search: { by, q }, ...cursor(query.after), limit })
|
||||
}
|
||||
|
||||
export function listImports(reads: CatalogReads, query: { after?: string; limit?: number }): Promise<Page<ImportView>> {
|
||||
return reads.listImports({ ...cursor(query.after), limit: query.limit ?? PAGE_LIMIT })
|
||||
}
|
||||
|
||||
export function applyImport(
|
||||
service: CatalogService,
|
||||
scope: ProjectScope,
|
||||
input: {
|
||||
fileHash: string
|
||||
sourceName: string
|
||||
format: "csv" | "xlsx" | "json"
|
||||
chunk: number
|
||||
finalChunk: boolean
|
||||
revision?: string
|
||||
rows: readonly ImportRow[]
|
||||
requestId: string
|
||||
},
|
||||
): Promise<ImportOutcomeView> {
|
||||
return service.applyImport({
|
||||
fileHash: input.fileHash,
|
||||
sourceName: input.sourceName.trim(),
|
||||
format: input.format,
|
||||
chunk: input.chunk,
|
||||
finalChunk: input.finalChunk,
|
||||
...(input.revision === undefined ? {} : { revision: input.revision }),
|
||||
rows: input.rows,
|
||||
key: requestKey(scope, input.requestId),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// The port the catalog module's use cases are written against: the platform's Catalog service, in this
|
||||
// module's own words. adapters/service.ts is the one place that knows `@conusai-sdk/catalog` exists, and
|
||||
// it turns every refusal into the kit's app errors (`businessAppError` of
|
||||
// `@conusai-kit/business`) before core sees it.
|
||||
|
||||
import { token } from "@conusai-kit/core"
|
||||
import type { ImportOutcomeView, ImportRow, ImportView, ProductSearchField, ProductView } from "../contract/index.js"
|
||||
|
||||
export interface Page<T> {
|
||||
readonly rows: T[]
|
||||
readonly after?: string
|
||||
}
|
||||
|
||||
/** What a `read` procedure may ask the service: nothing that changes a row. */
|
||||
export interface CatalogReads {
|
||||
listProducts(query: {
|
||||
search?: { by: Exclude<ProductSearchField, "name">; q: string }
|
||||
after?: string
|
||||
limit: number
|
||||
}): Promise<Page<ProductView>>
|
||||
/** The service's ranked search: the exact code first, then most shared words. */
|
||||
searchProducts(query: { q: string; after?: string; limit: number }): Promise<Page<ProductView>>
|
||||
getProduct(productId: string): Promise<ProductView>
|
||||
listImports(query: { after?: string; limit: number }): Promise<Page<ImportView>>
|
||||
}
|
||||
|
||||
export interface CatalogService extends CatalogReads {
|
||||
applyImport(input: {
|
||||
fileHash: string
|
||||
sourceName: string
|
||||
format: "csv" | "xlsx" | "json"
|
||||
chunk: number
|
||||
finalChunk: boolean
|
||||
revision?: string
|
||||
rows: readonly ImportRow[]
|
||||
key: string
|
||||
}): Promise<ImportOutcomeView>
|
||||
}
|
||||
|
||||
/** The read view a `read` or `write` handler receives: the kind is a capability, not a label. */
|
||||
export const catalogReadsOf = (service: CatalogReads): CatalogReads => ({
|
||||
listProducts: (query) => service.listProducts(query),
|
||||
searchProducts: (query) => service.searchProducts(query),
|
||||
getProduct: (productId) => service.getProduct(productId),
|
||||
listImports: (query) => service.listImports(query),
|
||||
})
|
||||
|
||||
/**
|
||||
* Bound by a composition root or a test that brings its own client (another organization, the
|
||||
* business-services fake). With nothing bound, the module calls this deployment's service.
|
||||
*/
|
||||
export const CATALOG_SERVICE = token<CatalogService>("catalog.service", {
|
||||
views: { read: catalogReadsOf, write: catalogReadsOf },
|
||||
})
|
||||
Reference in New Issue
Block a user