Skip to content

Commit e79094e

Browse files
committed
Add DocumentStore and SourceProvider ports with in-package fakes (CL-5226)
Export port types, fake implementations, and mount overrides so a host can wire storage and live sources without Postgres. Config is optional when documentStore is provided. MemoryProvider is a type stub only until M3.
1 parent 84d10ac commit e79094e

7 files changed

Lines changed: 880 additions & 6 deletions

File tree

src/index.ts

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,14 @@ import {
1414
createKnowledgePlane,
1515
type Generate,
1616
type KnowledgePlane,
17+
type KnowledgePlaneOptions,
18+
type TextExtractor,
1719
} from "./knowledge.ts";
20+
import type {
21+
DocumentStore,
22+
MemoryProvider,
23+
SourceProvider,
24+
} from "./ports/types.ts";
1825
import {
1926
mountKnowledgeRoutes,
2027
type GrantConfig,
@@ -58,6 +65,24 @@ export type {
5865
VisibilitySpec,
5966
} from "./knowledge.ts";
6067
export { KnowledgeError, KnowledgeNotPermittedError } from "./knowledge.ts";
68+
// Ports (M2) — pluggable storage + live sources; MemoryProvider type stub for M3
69+
export type {
70+
DocumentStore,
71+
DocumentStoreAddParams,
72+
DocumentStoreFindItem,
73+
DocumentStoreFindParams,
74+
DocumentStoreFindResult,
75+
DocumentStoreRecentEvent,
76+
DocumentStoreRecentParams,
77+
LiveSearchItem,
78+
MemoryProvider,
79+
SourceProvider,
80+
} from "./ports/types.ts";
81+
export {
82+
createFakeDocumentStore,
83+
createFakeMemoryProvider,
84+
createFakeSourceProvider,
85+
} from "./ports/fakes.ts";
6186
// Migrations
6287
export { runKnowledgeMigrations } from "./migrations.ts";
6388
// Degrade metrics — no metrics dependency exists in this package (see
@@ -80,7 +105,11 @@ export {
80105
export { mountKnowledgeRoutes, type GrantConfig } from "./routes/mount.ts";
81106

82107
export type MountKnowledgeEngineOptions = {
83-
config: KnowledgeConfig;
108+
/**
109+
* Engine config (DB + model endpoints). Optional when `documentStore` is
110+
* provided — a host can mount with fakes only.
111+
*/
112+
config?: KnowledgeConfig;
84113
/**
85114
* The host's grant store + condition registry — the same pair it passes to
86115
* `createApp`/`createRequireGrant`. Required: HTTP routes are guarded with
@@ -97,6 +126,14 @@ export type MountKnowledgeEngineOptions = {
97126
* retry, audit and authz gates. Wire this to that rather than to a bare fetch.
98127
*/
99128
generate?: Generate;
129+
/** Required for `add({ file })` via HTTP or plane. */
130+
textExtractor?: TextExtractor;
131+
/** Override durable storage (default: engine pgvector store). */
132+
documentStore?: DocumentStore;
133+
/** Live source connectors (merge wired in CL-5227). */
134+
sources?: SourceProvider[];
135+
/** Memory port accepted for wiring; product in M3. */
136+
memory?: MemoryProvider;
100137
};
101138

102139
export type MountedKnowledgeEngine = {
@@ -111,9 +148,18 @@ export function mountKnowledgeEngine(
111148
// Rerank config validation runs inside createKnowledgePlane so standalone
112149
// construction and the mount path share one check. Pass grants + generate so
113150
// the returned plane's ask() is grant-checked and can synthesize answers.
114-
const knowledge = createKnowledgePlane(options.config, options.grants, {
151+
const planeOptions: KnowledgePlaneOptions = {
115152
...(options.generate ? { generate: options.generate } : {}),
116-
});
153+
...(options.textExtractor ? { textExtractor: options.textExtractor } : {}),
154+
...(options.documentStore ? { documentStore: options.documentStore } : {}),
155+
...(options.sources ? { sources: options.sources } : {}),
156+
...(options.memory ? { memory: options.memory } : {}),
157+
};
158+
const knowledge = createKnowledgePlane(
159+
options.config,
160+
options.grants,
161+
planeOptions,
162+
);
117163
const deps: RouteDeps = {
118164
knowledge,
119165
grants: options.grants,

src/knowledge.ts

Lines changed: 197 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,23 @@ import {
3030
} from "./services/timeline.ts";
3131
import type { KnowledgeConfig } from "./mount-config.ts";
3232
import type { GrantConfig } from "./routes/deps.ts";
33+
import type {
34+
DocumentStore,
35+
MemoryProvider,
36+
SourceProvider,
37+
} from "./ports/types.ts";
3338

3439
// Re-export so hosts typing plane results don't reach into services/.
3540
export type { HybridSearchResult } from "./services/search.ts";
3641
export type { SearchHit } from "./core/schemas/search.ts";
3742
export type { VisibilitySpec } from "./core/schemas/document.ts";
43+
export type {
44+
DocumentStore,
45+
DocumentStoreAddParams,
46+
LiveSearchItem,
47+
MemoryProvider,
48+
SourceProvider,
49+
} from "./ports/types.ts";
3850

3951
export type ChatMessage = {
4052
role: "system" | "user" | "assistant";
@@ -291,6 +303,20 @@ export type KnowledgePlaneOptions = {
291303
generate?: Generate;
292304
/** Required for `add({ file })`; omit if the host only adds text content. */
293305
textExtractor?: TextExtractor;
306+
/**
307+
* Override durable storage. When set, the plane does not open Postgres or
308+
* call embed/rerank endpoints — useful for fakes and alternate backends.
309+
*/
310+
documentStore?: DocumentStore;
311+
/**
312+
* Live source connectors. Wired into find/ask merge in CL-5227; accepted
313+
* here so mounts can declare them early.
314+
*/
315+
sources?: SourceProvider[];
316+
/**
317+
* Memory port type accepted for mount wiring; remember/recall product is M3.
318+
*/
319+
memory?: MemoryProvider;
294320
};
295321

296322
function resolveFindLimit(limit: number | undefined): number {
@@ -407,12 +433,180 @@ function resolveShareAndVisibility(params: KnowledgeAddParams): {
407433
*
408434
* - `grants` is required for `ask()` (in-process capability check). Standalone
409435
* add/find callers may omit it — same as #8's out-of-band plane.
410-
* - Rerank config is validated at construction (same as mount).
436+
* - Rerank config is validated at construction (same as mount) when using the
437+
* default Postgres-backed store.
438+
* - Pass `options.documentStore` to skip Postgres entirely (fakes / overrides).
439+
* When a store is provided, `config` may be omitted.
411440
*/
412441
export function createKnowledgePlane(
413-
config: KnowledgeConfig,
442+
config: KnowledgeConfig | undefined,
414443
grants?: GrantConfig,
415444
options: KnowledgePlaneOptions = {},
445+
): KnowledgePlane {
446+
if (options.documentStore) {
447+
return createPlaneFromStore(options.documentStore, grants, options);
448+
}
449+
if (!config) {
450+
throw new KnowledgeError(
451+
500,
452+
"KnowledgeConfig is required when documentStore is not provided",
453+
);
454+
}
455+
return createPlaneFromEngine(config, grants, options);
456+
}
457+
458+
/** Plane backed by an injected DocumentStore (fake or host override). */
459+
function createPlaneFromStore(
460+
store: DocumentStore,
461+
grants: GrantConfig | undefined,
462+
options: KnowledgePlaneOptions,
463+
): KnowledgePlane {
464+
// sources/memory held for mount completeness; merge/memory product later.
465+
void options.sources;
466+
void options.memory;
467+
468+
const plane: KnowledgePlane = {
469+
async find(params) {
470+
const limit = resolveFindLimit(params.limit);
471+
return store.find({
472+
tenantId: params.tenantId,
473+
principalId: params.principalId,
474+
query: params.query,
475+
limit,
476+
...(params.includeEvidence !== undefined
477+
? { includeEvidence: params.includeEvidence }
478+
: {}),
479+
});
480+
},
481+
482+
async ask(params) {
483+
if (!grants) {
484+
throw new KnowledgeError(
485+
501,
486+
"ask() requires a GrantConfig. Pass grants to " +
487+
"createKnowledgePlane/mountKnowledgeEngine.",
488+
);
489+
}
490+
const decision = await authorize(
491+
grants.grantStore,
492+
params.principalId,
493+
params.tenantId,
494+
"knowledge",
495+
"find",
496+
grants.conditionRegistry,
497+
);
498+
if (decision.effect !== "allow") {
499+
const effect = decision.effect ?? "no-matching-grant";
500+
log.info(
501+
`ask: denied knowledge:find for ${params.principalId} (effect=${effect})`,
502+
{
503+
principalId: params.principalId,
504+
effect,
505+
},
506+
);
507+
throw new KnowledgeNotPermittedError();
508+
}
509+
if (!options.generate) {
510+
throw new KnowledgeError(
511+
501,
512+
"ask() requires a `generate` function. Pass one to " +
513+
"createKnowledgePlane/mountKnowledgeEngine, wired to your " +
514+
"inference layer.",
515+
);
516+
}
517+
const findResult = await plane.find({
518+
tenantId: params.tenantId,
519+
principalId: params.principalId,
520+
query: params.query,
521+
includeEvidence: true,
522+
...(params.limit !== undefined ? { limit: params.limit } : {}),
523+
});
524+
return synthesizeAnswer(
525+
params.query,
526+
{
527+
hits: findItemsToHits(findResult.items),
528+
evidence: findResult.evidence ?? "none",
529+
},
530+
options.generate,
531+
);
532+
},
533+
534+
async add(params) {
535+
const hasContent = params.content !== undefined;
536+
const hasFile = params.file !== undefined;
537+
if (hasContent === hasFile) {
538+
throw new KnowledgeError(
539+
400,
540+
"provide exactly one of content or file",
541+
);
542+
}
543+
544+
let title: string;
545+
let text: string;
546+
if (params.content) {
547+
title = params.content.title;
548+
text = params.content.text;
549+
} else {
550+
const file = params.file!;
551+
if (!options.textExtractor) {
552+
throw new KnowledgeError(
553+
400,
554+
"file requires a textExtractor on the knowledge plane",
555+
);
556+
}
557+
const extracted = await options.textExtractor.extract({
558+
bytes: file.bytes,
559+
...(file.mimeType !== undefined ? { mimeType: file.mimeType } : {}),
560+
...(file.filename !== undefined ? { filename: file.filename } : {}),
561+
});
562+
text = extracted.text;
563+
title =
564+
file.title ?? extracted.title ?? file.filename ?? "untitled";
565+
}
566+
567+
const { visibility, blockPrincipalIds } =
568+
resolveShareAndVisibility(params);
569+
570+
return store.add({
571+
tenantId: params.tenantId,
572+
principalId: params.principalId,
573+
title,
574+
text,
575+
visibility,
576+
...(blockPrincipalIds !== undefined ? { blockPrincipalIds } : {}),
577+
...(params.attributes !== undefined
578+
? { attributes: params.attributes }
579+
: {}),
580+
...(params.externalRef !== undefined
581+
? { externalRef: params.externalRef }
582+
: {}),
583+
});
584+
},
585+
586+
async recent(params) {
587+
const limit = resolveRecentLimit(params.limit);
588+
return store.recent({
589+
tenantId: params.tenantId,
590+
principalId: params.principalId,
591+
...(limit !== undefined ? { limit } : {}),
592+
});
593+
},
594+
595+
async close() {
596+
await store.close();
597+
},
598+
};
599+
600+
return plane;
601+
}
602+
603+
/**
604+
* Default plane: engine pgvector store + hybrid search.
605+
*/
606+
function createPlaneFromEngine(
607+
config: KnowledgeConfig,
608+
grants: GrantConfig | undefined,
609+
options: KnowledgePlaneOptions,
416610
): KnowledgePlane {
417611
// Catch a chunk-size / reranker-limit mismatch at construction time, rather
418612
// than silently on every find once the reranker starts rejecting batches.
@@ -561,7 +755,7 @@ export function createKnowledgePlane(
561755
// data layers are independent and BOTH must allow. Per-document
562756
// visibility (enforced inside `find`) is not a substitute for "may
563757
// this principal search at all".
564-
// Same action as HTTP find/ask/recent: knowledge:find.
758+
// Same action as HTTP find/ask/recent: knowledge:find.
565759
if (!grants) {
566760
throw new KnowledgeError(
567761
501,

0 commit comments

Comments
 (0)