@matatbread/matbot-core 0.2.9 → 0.3.1
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/package.json +2 -2
- package/src/about.ts +10 -12
- package/src/loader.ts +14 -3
- package/src/registry.ts +32 -0
- package/src/runner.ts +16 -1
- package/src/session-runner.ts +29 -1
- package/src/single-turn.ts +4 -8
- package/src/storage-base/query/execute.ts +7 -1
- package/src/storage-base/query/validate.ts +14 -0
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"node",
|
|
5
5
|
"browser"
|
|
6
6
|
],
|
|
7
|
-
"version": "0.
|
|
7
|
+
"version": "0.3.1",
|
|
8
8
|
"engines": {
|
|
9
9
|
"node": ">=24.0.0"
|
|
10
10
|
},
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"access": "public"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@matatbread/matbot-plugin-api": "^0.
|
|
38
|
+
"@matatbread/matbot-plugin-api": "^0.3.1"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"typescript": "^6.0.3"
|
package/src/about.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import type { Tool, ToolExecutor, ToolResultOf, ToolContext } from '@matatbread/matbot-plugin-api';
|
|
1
|
+
import type { Tool, ToolExecutor, ToolContract, ToolResultOf, ToolContext } from '@matatbread/matbot-plugin-api';
|
|
2
2
|
|
|
3
3
|
declare module '@matatbread/matbot-plugin-api' {
|
|
4
|
-
interface
|
|
5
|
-
about_matbot: { version: string; about: string }
|
|
4
|
+
interface ToolContracts {
|
|
5
|
+
about_matbot: ToolContract<{ version: string; about: string, currentProvider: string | undefined }, Record<string, never>>;
|
|
6
6
|
}
|
|
7
7
|
}
|
|
8
8
|
|
|
@@ -17,21 +17,19 @@ const ABOUT = 'Matbot composable LLM harness';
|
|
|
17
17
|
*/
|
|
18
18
|
export function createAboutMatbotTool(version: string): Tool<ToolResultOf<'about_matbot'>> {
|
|
19
19
|
const executor: ToolExecutor<ToolResultOf<'about_matbot'>> = {
|
|
20
|
-
async *execute(_input: unknown,
|
|
21
|
-
yield { type: 'result', value: { version, about: ABOUT } };
|
|
20
|
+
async *execute(_input: unknown, ctx: ToolContext) {
|
|
21
|
+
yield { type: 'result', value: { version, about: ABOUT, currentProvider: ctx.provider } };
|
|
22
22
|
},
|
|
23
23
|
};
|
|
24
24
|
|
|
25
25
|
return {
|
|
26
26
|
name: 'about_matbot',
|
|
27
27
|
description:
|
|
28
|
-
'Report what you are running: the
|
|
29
|
-
'
|
|
30
|
-
'
|
|
31
|
-
'
|
|
32
|
-
'
|
|
33
|
-
'{} // -> { version, about }\n' +
|
|
34
|
-
'```',
|
|
28
|
+
'Report what you are running right now: the current model / LLM and provider profile powering THIS ' +
|
|
29
|
+
'conversation, plus the matbot harness version and a one-line description. Use it whenever asked ' +
|
|
30
|
+
'"what model / LLM are you using?", "which provider is this?", what version of matbot this is, or for ' +
|
|
31
|
+
'an "about" of the harness itself — distinct from the `provider` tool (which lists all configured ' +
|
|
32
|
+
'provider profiles) and the `plugin` tool (which lists loaded plugins).',
|
|
35
33
|
inputSchema: { type: 'object', properties: {} },
|
|
36
34
|
executor,
|
|
37
35
|
};
|
package/src/loader.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { MatbotPlugin, MatbotPluginSpec, MatbotMachine, PluginSource, Runtime } from './plugin.js';
|
|
2
2
|
import type { PromptFn } from './types.js';
|
|
3
3
|
import { incompatibleRuntimeError, notAPluginError } from '@matatbread/matbot-plugin-api';
|
|
4
|
-
import { registerPlugin, setupPlugin, unloadPlugin } from './registry.js';
|
|
4
|
+
import { registerPlugin, setupPlugin, unloadPlugin, recordFailedPlugin, clearFailedPlugin } from './registry.js';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* The runtime this process is executing in. Detected the same way the rejected-import branch below
|
|
@@ -126,7 +126,9 @@ export async function loadPlugins(
|
|
|
126
126
|
if (onLoadError === 'throw') {
|
|
127
127
|
throw incompatibleRuntimeError(spec, runtimes, CURRENT_RUNTIME);
|
|
128
128
|
}
|
|
129
|
-
|
|
129
|
+
const reason = `declares matbotRuntime [${runtimes.join(', ')}], host runtime is "${CURRENT_RUNTIME}".`;
|
|
130
|
+
console.warn(`[matbot] Skipping plugin "${spec}": ${reason}`);
|
|
131
|
+
recordFailedPlugin({ specifier: spec, error: reason, ...(name !== undefined ? { name } : {}) });
|
|
130
132
|
continue;
|
|
131
133
|
}
|
|
132
134
|
toLoad.push({
|
|
@@ -158,6 +160,7 @@ export async function loadPlugins(
|
|
|
158
160
|
const failLoad = (spec: string, reason: string, kind: 'load' | 'shape' = 'load'): void => {
|
|
159
161
|
if (onLoadError === 'throw') throw kind === 'shape' ? notAPluginError(spec, reason) : new Error(reason);
|
|
160
162
|
console.warn(`[matbot] Skipping plugin "${spec}": ${reason}`);
|
|
163
|
+
recordFailedPlugin({ specifier: spec, error: reason });
|
|
161
164
|
};
|
|
162
165
|
|
|
163
166
|
for (let i = 0; i < toLoad.length; i++) {
|
|
@@ -169,6 +172,7 @@ export async function loadPlugins(
|
|
|
169
172
|
if (typeof window !== 'undefined') {
|
|
170
173
|
// Browser: warn and skip — browser environments have no node_modules fallback.
|
|
171
174
|
console.warn(`[matbot] Could not load plugin "${spec}" (browser: use a URL path or configure an import map): ${reason}`);
|
|
175
|
+
recordFailedPlugin({ specifier: spec, error: reason });
|
|
172
176
|
continue;
|
|
173
177
|
}
|
|
174
178
|
console.error(`[matbot] Failed to load plugin "${spec}":`, result.reason);
|
|
@@ -233,8 +237,15 @@ export async function loadPlugins(
|
|
|
233
237
|
registered = true;
|
|
234
238
|
await setupPlugin(plugin, services, prompt);
|
|
235
239
|
loaded.push(plugin);
|
|
240
|
+
// Successful (re)load: a fixed plugin drops off the failed list (reload runs this same path).
|
|
241
|
+
clearFailedPlugin(spec);
|
|
236
242
|
} catch (err) {
|
|
237
|
-
|
|
243
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
244
|
+
console.error(`[matbot] Ignoring plugin "${plugin.name}" from "${spec}" due to an error:`, reason);
|
|
245
|
+
// Graceful but not silent: record the setup failure (skip mode — the boot batch) so the `plugin`
|
|
246
|
+
// tool and web UI can show it. Explicit single loads (`onLoadError: 'throw'`) surface the error to
|
|
247
|
+
// the caller directly, so they are not added to the persistent failed list.
|
|
248
|
+
if (onLoadError === 'skip') recordFailedPlugin({ specifier: spec, name: plugin.name, error: reason });
|
|
238
249
|
// Roll back partial registration so a failed load leaves no ghost state. setupPlugin
|
|
239
250
|
// can throw after the plugin is already in the registry with some of its tools / hooks /
|
|
240
251
|
// services / provider / storage wired (a wrong-platform plugin touching a missing
|
package/src/registry.ts
CHANGED
|
@@ -11,6 +11,15 @@ import type { SettingsDoc } from './settings.js';
|
|
|
11
11
|
|
|
12
12
|
// ── Internal state ────────────────────────────────────────────────────────────
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* A plugin the loader could not bring up — an incompatible runtime, an import that rejected, a
|
|
16
|
+
* module that is not plugin-shaped, or a setup() throw. Recorded (not discarded) so the failure is
|
|
17
|
+
* *graceful but not silent*: the `plugin` tool and web UI can show which configured plugins failed
|
|
18
|
+
* and why, instead of the failure vanishing into a boot-time console line. `error` is pre-stringified
|
|
19
|
+
* (sidesteps the `unknown` catch-var); `name` is present only when known before the failure.
|
|
20
|
+
*/
|
|
21
|
+
export type FailedPlugin = { specifier: string; name?: string; error: string };
|
|
22
|
+
|
|
14
23
|
// Mutable arrays/maps held in a single object to make _resetRegistry() simple.
|
|
15
24
|
const state = {
|
|
16
25
|
plugins: [] as MatbotPlugin[],
|
|
@@ -21,6 +30,7 @@ const state = {
|
|
|
21
30
|
serviceKeys: new Map<string, string[]>(), // pluginName → MatbotMachine keys it registered
|
|
22
31
|
hookPlugins: new Set<string>(), // plugins that registered at least one hook
|
|
23
32
|
systemContextPlugins: new Set<string>(), // plugins that registered a system-context contributor
|
|
33
|
+
failedPlugins: [] as FailedPlugin[], // plugins the loader skipped, keyed by specifier (last failure wins)
|
|
24
34
|
overwriteAllTools: undefined as boolean | undefined, // persisted "overwrite on collision, this install" choice, loaded lazily
|
|
25
35
|
};
|
|
26
36
|
|
|
@@ -196,6 +206,28 @@ export function getRegisteredPlugins(): readonly MatbotPlugin[] {
|
|
|
196
206
|
return state.plugins;
|
|
197
207
|
}
|
|
198
208
|
|
|
209
|
+
/**
|
|
210
|
+
* Plugins the loader skipped rather than loaded — the graceful-but-not-silent record. One entry per
|
|
211
|
+
* specifier (a repeated failure overwrites the prior one; a subsequent successful load clears it via
|
|
212
|
+
* clearFailedPlugin). Surfaced by the `plugin` tool's `list` and the web plugins panel.
|
|
213
|
+
*/
|
|
214
|
+
export function getFailedPlugins(): readonly FailedPlugin[] {
|
|
215
|
+
return state.failedPlugins;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Record (or replace, by specifier) a plugin the loader skipped. Called from loader.ts skip points. */
|
|
219
|
+
export function recordFailedPlugin(entry: FailedPlugin): void {
|
|
220
|
+
const idx = state.failedPlugins.findIndex(f => f.specifier === entry.specifier);
|
|
221
|
+
if (idx === -1) state.failedPlugins.push(entry);
|
|
222
|
+
else state.failedPlugins[idx] = entry;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Drop any recorded failure for a specifier — a fixed + reloaded plugin drops off the failed list. */
|
|
226
|
+
export function clearFailedPlugin(specifier: string): void {
|
|
227
|
+
const idx = state.failedPlugins.findIndex(f => f.specifier === specifier);
|
|
228
|
+
if (idx !== -1) state.failedPlugins.splice(idx, 1);
|
|
229
|
+
}
|
|
230
|
+
|
|
199
231
|
export function getRegisteredFrontendPlugins(): ReadonlyMap<string, FrontendInfo> {
|
|
200
232
|
return state.frontendPlugins;
|
|
201
233
|
}
|
package/src/runner.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
Tool, ToolRegistry, ToolContext, Store, FileStore, SystemContextRegistry, Vault, PromptFn, FormField,
|
|
5
5
|
} from './types.js';
|
|
6
6
|
import type { MatbotPlugin } from './plugin.js';
|
|
7
|
+
import type { ToolPresenter } from '@matatbread/matbot-plugin-api';
|
|
7
8
|
import { currentUsageSink } from '@matatbread/matbot-plugin-api';
|
|
8
9
|
import { HookRegistry } from './hooks.js';
|
|
9
10
|
import { appendMessage, createMessage } from './session.js';
|
|
@@ -25,6 +26,14 @@ export interface RunSessionOpts {
|
|
|
25
26
|
* `tools`).
|
|
26
27
|
*/
|
|
27
28
|
toolRegistry?: ToolRegistry;
|
|
29
|
+
/**
|
|
30
|
+
* Optional presenter that picks which tools are advertised to the model on each provider call — a
|
|
31
|
+
* read-only view over the `tools` snapshot (e.g. deferring a large library behind a search tool and
|
|
32
|
+
* revealing matches mid-turn). Consulted *per provider call* so a reveal takes effect on the next
|
|
33
|
+
* loop iteration; absent ⇒ the whole snapshot is advertised (unchanged behaviour). Presentation never
|
|
34
|
+
* affects execution — a withheld tool still resolves by name through `toolRegistry`.
|
|
35
|
+
*/
|
|
36
|
+
toolPresenter?: ToolPresenter;
|
|
28
37
|
store: Store<Session>;
|
|
29
38
|
hooks?: HookRegistry;
|
|
30
39
|
systemContext?: SystemContextRegistry;
|
|
@@ -149,8 +158,14 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
|
|
|
149
158
|
outgoing: [...systemMsg, ...history],
|
|
150
159
|
session, config, signal,
|
|
151
160
|
});
|
|
161
|
+
// Tools advertised for THIS call. A presenter (optional) may present a subset of the snapshot and
|
|
162
|
+
// grow it across iterations as the model discovers tools; absent ⇒ the whole snapshot. Withheld
|
|
163
|
+
// tools stay callable — execution resolves against the live registry (opts.toolRegistry), not this.
|
|
164
|
+
const advertised = opts.toolPresenter
|
|
165
|
+
? await opts.toolPresenter.present([...tools.values()], { session, provider: config.provider })
|
|
166
|
+
: [...tools.values()];
|
|
152
167
|
try {
|
|
153
|
-
for await (const ev of provider.complete(outgoing, providerConfig,
|
|
168
|
+
for await (const ev of provider.complete(outgoing, providerConfig, advertised, signal)) {
|
|
154
169
|
switch (ev.type) {
|
|
155
170
|
case 'text-delta':
|
|
156
171
|
textAcc += ev.delta;
|
package/src/session-runner.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
} from './types.js';
|
|
6
6
|
import type { MatbotPlugin } from './plugin.js';
|
|
7
7
|
import type { HookRegistry } from './hooks.js';
|
|
8
|
+
import type { ToolTypeIndex, ToolPresenter } from '@matatbread/matbot-plugin-api';
|
|
8
9
|
import { appendMessage, createMessage } from './session.js';
|
|
9
10
|
import { contextSwitch, withUsageScope } from '@matatbread/matbot-plugin-api';
|
|
10
11
|
import { runSession } from './runner.js';
|
|
@@ -13,6 +14,14 @@ export interface SessionRunnerDeps {
|
|
|
13
14
|
store: Store<Session>;
|
|
14
15
|
resolveProvider: (name: string) => Promise<{ adapter: ProviderAdapter; config: ProviderConfig } | null>;
|
|
15
16
|
tools?: ToolRegistry;
|
|
17
|
+
// Resolved live (the service registers after boot): supplies the per-tool wire contract derived from each
|
|
18
|
+
// tool's single contract source (the `ToolContracts` arms, or a source-less tool's `toolContract`), folded
|
|
19
|
+
// into the turn-start tool snapshot's description so the wire carries params/result text. Absent (e.g.
|
|
20
|
+
// browser) ⇒ tools keep their plain description.
|
|
21
|
+
toolTypeIndex?: () => ToolTypeIndex | undefined;
|
|
22
|
+
// Resolved live (registers after boot): picks which tools are advertised to the model per provider
|
|
23
|
+
// call. Absent ⇒ the whole turn snapshot is advertised. Threaded into runSession per turn.
|
|
24
|
+
toolPresenter?: () => ToolPresenter | undefined;
|
|
16
25
|
hooks?: HookRegistry;
|
|
17
26
|
systemContext?: SystemContextRegistry;
|
|
18
27
|
vault?: Vault;
|
|
@@ -113,6 +122,12 @@ function createSink(dispose: () => void): Sink {
|
|
|
113
122
|
};
|
|
114
123
|
}
|
|
115
124
|
|
|
125
|
+
export function wireDescription(description: string, wc: { params: string; result: string } | undefined) {
|
|
126
|
+
return wc
|
|
127
|
+
? `${description}\n\nTypeScript params:\n\`\`\`\n${wc.params}\n\`\`\`\n\nTypeScript result:\`\`\`${wc.result}\n\`\`\``
|
|
128
|
+
: description;
|
|
129
|
+
}
|
|
130
|
+
|
|
116
131
|
export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
117
132
|
const states = new Map<string, SessionState>();
|
|
118
133
|
|
|
@@ -207,8 +222,20 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
207
222
|
|
|
208
223
|
const ac = new AbortController();
|
|
209
224
|
s.ac = ac;
|
|
225
|
+
// Fold each tool's wire contract — the `params`/`result` text flattened from its single contract
|
|
226
|
+
// (a source tool's ToolContracts arms, or a source-less tool's `toolContract`) — into its description
|
|
227
|
+
// for this turn, so the model reasons about the real TS shapes, not just the loose inputSchema.
|
|
228
|
+
// Derived here because `services`/ToolTypeIndex are live at dispatch. Tools with only a loose
|
|
229
|
+
// inputSchema (no contract) are left as-is; absent ToolTypeIndex (browser) ⇒ plain descriptions.
|
|
230
|
+
const wire = await deps.toolTypeIndex?.()?.wireContracts();
|
|
231
|
+
const toolPresenter = deps.toolPresenter?.();
|
|
210
232
|
const toolMap = deps.tools !== undefined
|
|
211
|
-
? new Map<string, Tool>(deps.tools.list().map(t =>
|
|
233
|
+
? new Map<string, Tool>(deps.tools.list().map(t => {
|
|
234
|
+
const wc = wire?.[t.name];
|
|
235
|
+
return [t.name, wc !== undefined
|
|
236
|
+
? { ...t, description: wireDescription(t.description, wc) }
|
|
237
|
+
: t];
|
|
238
|
+
}))
|
|
212
239
|
: undefined;
|
|
213
240
|
|
|
214
241
|
try {
|
|
@@ -231,6 +258,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
231
258
|
unloadPlugin: deps.unloadPlugin,
|
|
232
259
|
...(toolMap !== undefined ? { tools: toolMap } : {}),
|
|
233
260
|
...(deps.tools !== undefined ? { toolRegistry: deps.tools } : {}),
|
|
261
|
+
...(toolPresenter !== undefined ? { toolPresenter } : {}),
|
|
234
262
|
...(deps.hooks !== undefined ? { hooks: deps.hooks } : {}),
|
|
235
263
|
...(deps.systemContext !== undefined ? { systemContext: deps.systemContext } : {}),
|
|
236
264
|
...(deps.workdir !== undefined ? { workdir: deps.workdir } : {}),
|
package/src/single-turn.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import type { Tool, ToolExecutor, ToolResultOf, ToolContext, MatbotMachine } from '@matatbread/matbot-plugin-api';
|
|
1
|
+
import type { Tool, ToolExecutor, ToolContract, ToolResultOf, ToolContext, MatbotMachine } from '@matatbread/matbot-plugin-api';
|
|
2
2
|
|
|
3
3
|
declare module '@matatbread/matbot-plugin-api' {
|
|
4
|
-
interface
|
|
5
|
-
single_turn: { text: string }; // the consulted provider's reply text
|
|
4
|
+
interface ToolContracts {
|
|
5
|
+
single_turn: ToolContract<{ text: string }, { provider?: string; prompt: string; system?: string }>; // the consulted provider's reply text
|
|
6
6
|
}
|
|
7
7
|
}
|
|
8
8
|
|
|
@@ -53,11 +53,7 @@ export function createSingleTurnTool(services: MatbotMachine): Tool<ToolResultOf
|
|
|
53
53
|
'back its text. Use it to consult a different model — e.g. a second, ' +
|
|
54
54
|
'different-lineage model critiquing your draft, or any generation that should run on a specific ' +
|
|
55
55
|
'provider. `provider` is OPTIONAL: omit it to run on the current conversation\'s model, or name ' +
|
|
56
|
-
'a configured provider to switch models (list or add providers with the provider tool)
|
|
57
|
-
'Parameters (TypeScript):\n' +
|
|
58
|
-
'```ts\n' +
|
|
59
|
-
'{ provider?: string; prompt: string; system?: string } // -> { text }\n' +
|
|
60
|
-
'```',
|
|
56
|
+
'a configured provider to switch models (list or add providers with the provider tool).',
|
|
61
57
|
inputSchema: {
|
|
62
58
|
type: 'object',
|
|
63
59
|
required: ['prompt'],
|
|
@@ -22,6 +22,11 @@ function toQuery(p: PageState): StoreQuery {
|
|
|
22
22
|
// disjoint cover (page N re-applies the same sort as page 1, so the total order never shifts). A
|
|
23
23
|
// cursor is untrusted input, so its decoded query is validated exactly like a fresh one.
|
|
24
24
|
export function executeQuery<T extends { id: string; version: string }>(docs: T[], q: StoreQuery): QueryResult<T> {
|
|
25
|
+
// Validate the caller's RAW query first. The projection below keeps only where/sort/limit, so an
|
|
26
|
+
// unknown top-level key (e.g. `filter` instead of `where`) would otherwise be silently discarded
|
|
27
|
+
// and the query would degrade to match-everything with no feedback to the author.
|
|
28
|
+
validateQuery(q);
|
|
29
|
+
|
|
25
30
|
const page: PageState = q.cursor !== undefined
|
|
26
31
|
? decodeCursor(q.cursor)
|
|
27
32
|
: {
|
|
@@ -31,7 +36,8 @@ export function executeQuery<T extends { id: string; version: string }>(docs: T[
|
|
|
31
36
|
...(q.limit !== undefined ? { limit: q.limit } : {}),
|
|
32
37
|
};
|
|
33
38
|
|
|
34
|
-
|
|
39
|
+
// A cursor is untrusted input, so its decoded query is validated exactly like a fresh one.
|
|
40
|
+
if (q.cursor !== undefined) validateQuery(toQuery(page));
|
|
35
41
|
|
|
36
42
|
const predicate = page.where !== undefined ? compileFilter(page.where) : () => true;
|
|
37
43
|
const ordered = applySort(docs.filter(predicate), page.sort);
|
|
@@ -93,7 +93,21 @@ function validateFilter(f: Filter, ptr: string): void {
|
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
const QUERY_KEYS = new Set(['where', 'sort', 'limit', 'cursor']);
|
|
97
|
+
|
|
96
98
|
export function validateQuery(q: StoreQuery): void {
|
|
99
|
+
if (q === null || typeof q !== 'object' || Array.isArray(q))
|
|
100
|
+
throw new StoreQueryError('query must be an object with optional keys: where, sort, limit, cursor', '/', 'MALFORMED');
|
|
101
|
+
|
|
102
|
+
// Reject unknown top-level keys. Without this the envelope is silently permissive: a clause placed
|
|
103
|
+
// under a wrong key (e.g. `filter` instead of `where`, an SQL-ism LLMs reach for) is dropped, the
|
|
104
|
+
// query degrades to match-everything, and the author gets a plausible result with no signal it was
|
|
105
|
+
// malformed. Naming the bad key and the valid set turns a silent miss into a fixable error.
|
|
106
|
+
for (const key of Object.keys(q)) {
|
|
107
|
+
if (!QUERY_KEYS.has(key))
|
|
108
|
+
throw new StoreQueryError(`unknown query key '${key}' — valid keys are: where, sort, limit, cursor`, `/${key}`, 'MALFORMED');
|
|
109
|
+
}
|
|
110
|
+
|
|
97
111
|
if (q.where !== undefined) validateFilter(q.where, '/where');
|
|
98
112
|
|
|
99
113
|
if (q.sort !== undefined) {
|