contentbase 0.0.2 → 0.0.5
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/CLI.md +593 -0
- package/MCP-SERVER-SPEC.md +453 -0
- package/PRIMER.md +540 -0
- package/README.md +289 -13
- package/docs/README.md +110 -0
- package/docs/TABLE-OF-CONTENTS.md +7 -0
- package/docs/models.ts +38 -0
- package/models.ts +38 -0
- package/package.json +12 -3
- package/src/api/endpoints/actions.ts +34 -0
- package/src/api/endpoints/doc.ts +187 -0
- package/src/api/endpoints/docs-index.ts +26 -0
- package/src/api/endpoints/document.ts +171 -0
- package/src/api/endpoints/documents.ts +71 -0
- package/src/api/endpoints/inspect.ts +16 -0
- package/src/api/endpoints/models.ts +10 -0
- package/src/api/endpoints/query.ts +88 -0
- package/src/api/endpoints/root.ts +7 -0
- package/src/api/endpoints/search-reindex.ts +65 -0
- package/src/api/endpoints/search-status.ts +55 -0
- package/src/api/endpoints/search.ts +104 -0
- package/src/api/endpoints/semantic-search.ts +120 -0
- package/src/api/endpoints/text-search.ts +63 -0
- package/src/api/endpoints/validate.ts +34 -0
- package/src/api/helpers.ts +97 -0
- package/src/base-model.ts +12 -0
- package/src/cli/commands/action.ts +82 -44
- package/src/cli/commands/console.ts +124 -0
- package/src/cli/commands/create.ts +179 -53
- package/src/cli/commands/embed.ts +323 -0
- package/src/cli/commands/export.ts +58 -24
- package/src/cli/commands/extract.ts +174 -0
- package/src/cli/commands/help.ts +81 -0
- package/src/cli/commands/index.ts +17 -0
- package/src/cli/commands/init.ts +72 -48
- package/src/cli/commands/inspect.ts +56 -46
- package/src/cli/commands/mcp.ts +1219 -0
- package/src/cli/commands/search.ts +285 -0
- package/src/cli/commands/serve.ts +348 -0
- package/src/cli/commands/summary.ts +60 -0
- package/src/cli/commands/teach.ts +86 -0
- package/src/cli/commands/text-search.ts +134 -0
- package/src/cli/commands/validate.ts +126 -64
- package/src/cli/index.ts +88 -19
- package/src/cli/load-collection.ts +144 -17
- package/src/cli/registry.ts +28 -0
- package/src/collection.ts +361 -10
- package/src/define-model.ts +101 -4
- package/src/document.ts +47 -1
- package/src/extract-sections.ts +1 -1
- package/src/index.ts +14 -2
- package/src/model-instance.ts +35 -5
- package/src/node-shortcuts.ts +1 -1
- package/src/query/collection-query.ts +96 -9
- package/src/query/index.ts +7 -0
- package/src/query/query-dsl.ts +259 -0
- package/src/relationships/has-many.ts +39 -0
- package/src/relationships/index.ts +7 -2
- package/src/section.ts +2 -0
- package/src/types.ts +23 -1
- package/src/utils/index.ts +1 -0
- package/src/utils/match-pattern.ts +65 -0
- package/src/validator.ts +18 -1
- package/bun.lock +0 -473
- package/examples/sdlc-queries.ts +0 -161
- package/scripts/examples/01-collection-setup.ts +0 -46
- package/scripts/examples/02-querying.ts +0 -67
- package/scripts/examples/03-sections.ts +0 -36
- package/scripts/examples/04-relationships.ts +0 -54
- package/scripts/examples/05-document-api.ts +0 -54
- package/scripts/examples/06-extract-sections.ts +0 -55
- package/scripts/examples/07-validation.ts +0 -46
- package/scripts/examples/08-serialization.ts +0 -51
- package/scripts/examples/lib/format.ts +0 -87
- package/scripts/examples/lib/setup.ts +0 -21
- package/scripts/examples/run-all.ts +0 -43
- package/showcases/national-parks/models.ts +0 -74
- package/showcases/national-parks/parks/acadia.mdx +0 -40
- package/showcases/national-parks/parks/yosemite.mdx +0 -44
- package/showcases/national-parks/parks/zion.mdx +0 -44
- package/showcases/national-parks/queries.ts +0 -103
- package/showcases/national-parks/trails/angels-landing.mdx +0 -19
- package/showcases/national-parks/trails/cathedral-lakes.mdx +0 -19
- package/showcases/national-parks/trails/half-dome.mdx +0 -19
- package/showcases/national-parks/trails/jordan-pond-path.mdx +0 -19
- package/showcases/national-parks/trails/mist-trail.mdx +0 -19
- package/showcases/national-parks/trails/observation-point.mdx +0 -19
- package/showcases/national-parks/trails/precipice-trail.mdx +0 -19
- package/showcases/national-parks/trails/the-narrows.mdx +0 -19
- package/showcases/recipes/cuisines/chinese.mdx +0 -28
- package/showcases/recipes/cuisines/italian.mdx +0 -32
- package/showcases/recipes/cuisines/mexican.mdx +0 -28
- package/showcases/recipes/models.ts +0 -77
- package/showcases/recipes/queries.ts +0 -89
- package/showcases/recipes/recipes/chinese/egg-fried-rice.mdx +0 -43
- package/showcases/recipes/recipes/chinese/mapo-tofu.mdx +0 -47
- package/showcases/recipes/recipes/italian/bruschetta.mdx +0 -38
- package/showcases/recipes/recipes/italian/cacio-e-pepe.mdx +0 -39
- package/showcases/recipes/recipes/italian/tiramisu.mdx +0 -43
- package/showcases/recipes/recipes/mexican/chicken-tinga.mdx +0 -44
- package/showcases/recipes/recipes/mexican/guacamole.mdx +0 -39
- package/showcases/vinyl-collection/albums/bitches-brew.mdx +0 -36
- package/showcases/vinyl-collection/albums/i-put-a-spell-on-you.mdx +0 -35
- package/showcases/vinyl-collection/albums/in-rainbows.mdx +0 -35
- package/showcases/vinyl-collection/albums/kind-of-blue.mdx +0 -32
- package/showcases/vinyl-collection/albums/ok-computer.mdx +0 -37
- package/showcases/vinyl-collection/albums/wild-is-the-wind.mdx +0 -35
- package/showcases/vinyl-collection/artists/miles-davis.mdx +0 -27
- package/showcases/vinyl-collection/artists/nina-simone.mdx +0 -26
- package/showcases/vinyl-collection/artists/radiohead.mdx +0 -27
- package/showcases/vinyl-collection/models.ts +0 -73
- package/showcases/vinyl-collection/queries.ts +0 -87
- package/test/ast-query.test.ts +0 -128
- package/test/collection.test.ts +0 -99
- package/test/define-model.test.ts +0 -78
- package/test/document.test.ts +0 -225
- package/test/extract-sections.test.ts +0 -356
- package/test/fixtures/sdlc/epics/authentication.mdx +0 -42
- package/test/fixtures/sdlc/epics/searching-and-browsing.mdx +0 -21
- package/test/fixtures/sdlc/models.ts +0 -89
- package/test/fixtures/sdlc/stories/authentication/a-user-should-be-able-to-register.mdx +0 -20
- package/test/helpers.ts +0 -21
- package/test/model-instance.test.ts +0 -197
- package/test/query.test.ts +0 -167
- package/test/relationships.test.ts +0 -84
- package/test/section.test.ts +0 -99
- package/test/table-of-contents.test.ts +0 -91
- package/test/validator.test.ts +0 -62
- package/tsconfig.json +0 -18
- package/vitest.config.ts +0 -11
package/src/document.ts
CHANGED
|
@@ -54,6 +54,7 @@ export class Document {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
get title(): string {
|
|
57
|
+
if (this.#meta.title) return String(this.#meta.title);
|
|
57
58
|
const heading = this.astQuery.select("heading");
|
|
58
59
|
return heading ? toString(heading) : this.id;
|
|
59
60
|
}
|
|
@@ -62,6 +63,18 @@ export class Document {
|
|
|
62
63
|
return kebabCase(this.title.toLowerCase());
|
|
63
64
|
}
|
|
64
65
|
|
|
66
|
+
get createdAt(): Date | undefined {
|
|
67
|
+
return this.collection.items.get(this.id)?.createdAt;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
get updatedAt(): Date | undefined {
|
|
71
|
+
return this.collection.items.get(this.id)?.updatedAt;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
get size(): number | undefined {
|
|
75
|
+
return this.collection.items.get(this.id)?.size;
|
|
76
|
+
}
|
|
77
|
+
|
|
65
78
|
get rawContent(): string {
|
|
66
79
|
if (Object.keys(this.#meta).length === 0) {
|
|
67
80
|
return this.content;
|
|
@@ -71,7 +84,7 @@ export class Document {
|
|
|
71
84
|
}
|
|
72
85
|
|
|
73
86
|
get path(): string {
|
|
74
|
-
return this.collection.resolve(this.id) + ".
|
|
87
|
+
return this.collection.resolve(this.id) + ".md";
|
|
75
88
|
}
|
|
76
89
|
|
|
77
90
|
// ─── Processor ───
|
|
@@ -112,6 +125,11 @@ export class Document {
|
|
|
112
125
|
heading = this.astQuery.findHeadingByText(startHeading) as
|
|
113
126
|
| Content
|
|
114
127
|
| undefined;
|
|
128
|
+
|
|
129
|
+
// If not found, try alternatives from the model definition's sections
|
|
130
|
+
if (!heading) {
|
|
131
|
+
heading = this.#resolveAlternativeHeading(startHeading);
|
|
132
|
+
}
|
|
115
133
|
} else {
|
|
116
134
|
heading = startHeading;
|
|
117
135
|
}
|
|
@@ -128,6 +146,28 @@ export class Document {
|
|
|
128
146
|
return [heading, ...sectionNodes];
|
|
129
147
|
}
|
|
130
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Look up section alternatives from the model definition.
|
|
151
|
+
* If the requested heading matches a section's heading or is one of its alternatives,
|
|
152
|
+
* try all variants (primary + alternatives) until one is found in the AST.
|
|
153
|
+
*/
|
|
154
|
+
#resolveAlternativeHeading(requestedHeading: string): Content | undefined {
|
|
155
|
+
const def = this.collection?.findModelDefinition?.(this.id);
|
|
156
|
+
if (!def?.sections) return undefined;
|
|
157
|
+
|
|
158
|
+
for (const sectionDef of Object.values(def.sections) as any[]) {
|
|
159
|
+
const allNames = [sectionDef.heading, ...(sectionDef.alternatives ?? [])];
|
|
160
|
+
if (!allNames.some((n: string) => n === requestedHeading)) continue;
|
|
161
|
+
|
|
162
|
+
for (const name of allNames) {
|
|
163
|
+
const found = this.astQuery.findHeadingByText(name) as Content | undefined;
|
|
164
|
+
if (found) return found;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
|
|
131
171
|
/**
|
|
132
172
|
* Returns an AstQuery scoped to the nodes underneath a particular heading,
|
|
133
173
|
* excluding the heading itself.
|
|
@@ -388,12 +428,18 @@ export class Document {
|
|
|
388
428
|
meta: Record<string, unknown>;
|
|
389
429
|
content: string;
|
|
390
430
|
ast: Root;
|
|
431
|
+
createdAt: Date | undefined;
|
|
432
|
+
updatedAt: Date | undefined;
|
|
433
|
+
size: number | undefined;
|
|
391
434
|
} {
|
|
392
435
|
return {
|
|
393
436
|
id: this.id,
|
|
394
437
|
meta: this.meta,
|
|
395
438
|
content: this.content,
|
|
396
439
|
ast: this.ast,
|
|
440
|
+
createdAt: this.createdAt,
|
|
441
|
+
updatedAt: this.updatedAt,
|
|
442
|
+
size: this.size,
|
|
397
443
|
};
|
|
398
444
|
}
|
|
399
445
|
|
package/src/extract-sections.ts
CHANGED
|
@@ -62,7 +62,7 @@ function cloneAndShiftHeadings(
|
|
|
62
62
|
): RootContent[] {
|
|
63
63
|
if (nodes.length === 0) return [];
|
|
64
64
|
|
|
65
|
-
const firstNode = nodes[0]
|
|
65
|
+
const firstNode = nodes[0]!;
|
|
66
66
|
const originalDepth =
|
|
67
67
|
firstNode.type === "heading" ? (firstNode as Heading).depth : 1;
|
|
68
68
|
const depthShift = targetDepth - originalDepth;
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Core classes
|
|
2
|
-
export { Collection } from "./collection";
|
|
2
|
+
export { Collection, introspectMetaSchema } from "./collection";
|
|
3
|
+
export type { FieldInfo } from "./collection";
|
|
3
4
|
export { Document } from "./document";
|
|
4
5
|
export { AstQuery } from "./ast-query";
|
|
5
6
|
export { NodeShortcuts } from "./node-shortcuts";
|
|
@@ -13,13 +14,21 @@ export type {
|
|
|
13
14
|
} from "./extract-sections";
|
|
14
15
|
|
|
15
16
|
// defineModel and helpers
|
|
16
|
-
export { defineModel } from "./define-model";
|
|
17
|
+
export { defineModel, generateDescription } from "./define-model";
|
|
18
|
+
export { Base } from "./base-model";
|
|
17
19
|
export { section } from "./section";
|
|
18
20
|
export { hasMany, belongsTo } from "./relationships/index";
|
|
19
21
|
|
|
20
22
|
// Query
|
|
21
23
|
export { CollectionQuery } from "./query/collection-query";
|
|
22
24
|
export { QueryBuilder } from "./query/query-builder";
|
|
25
|
+
export {
|
|
26
|
+
queryDSLSchema,
|
|
27
|
+
parseWhereClause,
|
|
28
|
+
parseSortClause,
|
|
29
|
+
executeQueryDSL,
|
|
30
|
+
} from "./query/query-dsl";
|
|
31
|
+
export type { QueryDSL } from "./query/query-dsl";
|
|
23
32
|
|
|
24
33
|
// Model instance factory (advanced use)
|
|
25
34
|
export { createModelInstance } from "./model-instance";
|
|
@@ -27,6 +36,9 @@ export { createModelInstance } from "./model-instance";
|
|
|
27
36
|
// Validation
|
|
28
37
|
export { validateDocument } from "./validator";
|
|
29
38
|
|
|
39
|
+
// Pattern matching
|
|
40
|
+
export { matchPattern, matchPatterns } from "./utils/match-pattern";
|
|
41
|
+
|
|
30
42
|
import { toString } from "mdast-util-to-string";
|
|
31
43
|
|
|
32
44
|
// Types
|
package/src/model-instance.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type { Document } from "./document";
|
|
|
12
12
|
import type { Collection } from "./collection";
|
|
13
13
|
import { HasManyRelationship } from "./relationships/has-many";
|
|
14
14
|
import { BelongsToRelationship } from "./relationships/belongs-to";
|
|
15
|
+
import { matchPatterns } from "./utils/match-pattern";
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Creates a model instance from a document and its model definition.
|
|
@@ -26,8 +27,11 @@ export function createModelInstance<
|
|
|
26
27
|
definition: TDef,
|
|
27
28
|
collection: Collection
|
|
28
29
|
): InferModelInstance<TDef> {
|
|
29
|
-
// ─── Meta: merge defaults,
|
|
30
|
-
const
|
|
30
|
+
// ─── Meta: merge defaults, pattern-inferred values, then frontmatter ───
|
|
31
|
+
const patternMeta = definition.pattern
|
|
32
|
+
? matchPatterns(definition.pattern, document.id) ?? {}
|
|
33
|
+
: {};
|
|
34
|
+
const rawMeta = { ...(definition.defaults ?? {}), ...patternMeta, ...document.meta };
|
|
31
35
|
let meta: any;
|
|
32
36
|
try {
|
|
33
37
|
meta = definition.meta.parse(rawMeta);
|
|
@@ -46,7 +50,19 @@ export function createModelInstance<
|
|
|
46
50
|
Object.defineProperty(sections, key, {
|
|
47
51
|
get() {
|
|
48
52
|
if (!cached) {
|
|
49
|
-
|
|
53
|
+
let heading = sd.heading;
|
|
54
|
+
if (sd.alternatives?.length) {
|
|
55
|
+
const found = document.astQuery.findHeadingByText(heading);
|
|
56
|
+
if (!found) {
|
|
57
|
+
for (const alt of sd.alternatives) {
|
|
58
|
+
if (document.astQuery.findHeadingByText(alt)) {
|
|
59
|
+
heading = alt;
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const sectionQuery = document.querySection(heading);
|
|
50
66
|
cached = { value: sd.extract(sectionQuery) };
|
|
51
67
|
}
|
|
52
68
|
return cached.value;
|
|
@@ -152,6 +168,9 @@ export function createModelInstance<
|
|
|
152
168
|
id: document.id,
|
|
153
169
|
title: document.title,
|
|
154
170
|
meta,
|
|
171
|
+
createdAt: document.createdAt,
|
|
172
|
+
updatedAt: document.updatedAt,
|
|
173
|
+
size: document.size,
|
|
155
174
|
};
|
|
156
175
|
|
|
157
176
|
// Include requested sections
|
|
@@ -198,8 +217,15 @@ export function createModelInstance<
|
|
|
198
217
|
get slug() {
|
|
199
218
|
return document.slug;
|
|
200
219
|
},
|
|
201
|
-
|
|
202
|
-
|
|
220
|
+
get createdAt() {
|
|
221
|
+
return document.createdAt;
|
|
222
|
+
},
|
|
223
|
+
get updatedAt() {
|
|
224
|
+
return document.updatedAt;
|
|
225
|
+
},
|
|
226
|
+
get size() {
|
|
227
|
+
return document.size;
|
|
228
|
+
},
|
|
203
229
|
meta,
|
|
204
230
|
sections,
|
|
205
231
|
relationships,
|
|
@@ -223,5 +249,9 @@ export function createModelInstance<
|
|
|
223
249
|
},
|
|
224
250
|
};
|
|
225
251
|
|
|
252
|
+
// Keep heavy back-references accessible but out of REPL output
|
|
253
|
+
Object.defineProperty(instance, "document", { value: document, enumerable: false });
|
|
254
|
+
Object.defineProperty(instance, "collection", { value: collection, enumerable: false });
|
|
255
|
+
|
|
226
256
|
return instance as InferModelInstance<TDef>;
|
|
227
257
|
}
|
package/src/node-shortcuts.ts
CHANGED
|
@@ -50,7 +50,7 @@ export class NodeShortcuts {
|
|
|
50
50
|
return this.headings.reduce(
|
|
51
51
|
(memo, heading) => {
|
|
52
52
|
memo[heading.depth] = memo[heading.depth] || [];
|
|
53
|
-
memo[heading.depth]
|
|
53
|
+
memo[heading.depth]!.push(heading);
|
|
54
54
|
return memo;
|
|
55
55
|
},
|
|
56
56
|
{} as Record<number, Heading[]>
|
|
@@ -2,7 +2,7 @@ import { QueryBuilder } from "./query-builder";
|
|
|
2
2
|
import { operators } from "./operators";
|
|
3
3
|
import { createModelInstance } from "../model-instance";
|
|
4
4
|
import type { Collection } from "../collection";
|
|
5
|
-
import type { ModelDefinition, InferModelInstance } from "../types";
|
|
5
|
+
import type { ModelDefinition, InferModelInstance, SerializeOptions } from "../types";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* CollectionQuery is a typed query builder for a specific model type.
|
|
@@ -12,14 +12,24 @@ import type { ModelDefinition, InferModelInstance } from "../types";
|
|
|
12
12
|
* const results = await collection
|
|
13
13
|
* .query(Epic)
|
|
14
14
|
* .where("meta.priority", "high")
|
|
15
|
+
* .include("plans")
|
|
15
16
|
* .fetchAll();
|
|
16
17
|
*/
|
|
18
|
+
interface SortSpec {
|
|
19
|
+
path: string;
|
|
20
|
+
direction: "asc" | "desc";
|
|
21
|
+
}
|
|
22
|
+
|
|
17
23
|
export class CollectionQuery<
|
|
18
24
|
TDef extends ModelDefinition<any, any, any, any, any>,
|
|
19
25
|
> {
|
|
20
26
|
#collection: Collection;
|
|
21
27
|
#definition: TDef;
|
|
22
28
|
#queryBuilder: QueryBuilder;
|
|
29
|
+
#sorts: SortSpec[] = [];
|
|
30
|
+
#limit: number | undefined;
|
|
31
|
+
#offset: number | undefined;
|
|
32
|
+
#include: string[] = [];
|
|
23
33
|
|
|
24
34
|
constructor(collection: Collection, definition: TDef) {
|
|
25
35
|
this.#collection = collection;
|
|
@@ -27,6 +37,26 @@ export class CollectionQuery<
|
|
|
27
37
|
this.#queryBuilder = new QueryBuilder();
|
|
28
38
|
}
|
|
29
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Include related models in query results.
|
|
42
|
+
* Named relationships will be eagerly resolved and included in toJSON() output.
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* await collection.query(Project).include("plans").fetchAll()
|
|
46
|
+
* await collection.query(Project).include("plans", "goal").fetchAll()
|
|
47
|
+
*/
|
|
48
|
+
include(...names: string[]): this {
|
|
49
|
+
this.#include.push(...names);
|
|
50
|
+
return this;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Returns the serialize options based on include() calls */
|
|
54
|
+
get serializeOptions(): SerializeOptions {
|
|
55
|
+
const opts: SerializeOptions = {};
|
|
56
|
+
if (this.#include.length > 0) opts.related = this.#include;
|
|
57
|
+
return opts;
|
|
58
|
+
}
|
|
59
|
+
|
|
30
60
|
where(
|
|
31
61
|
pathOrObject: string | Record<string, unknown>,
|
|
32
62
|
operatorOrValue?: any,
|
|
@@ -96,22 +126,41 @@ export class CollectionQuery<
|
|
|
96
126
|
return this;
|
|
97
127
|
}
|
|
98
128
|
|
|
129
|
+
scope(name: string): this {
|
|
130
|
+
const scopeFn = (this.#definition as any).scopes?.[name];
|
|
131
|
+
if (!scopeFn) {
|
|
132
|
+
throw new Error(`Unknown scope "${name}" on model "${this.#definition.name}"`);
|
|
133
|
+
}
|
|
134
|
+
return scopeFn(this) as this;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
sort(path: string, direction: "asc" | "desc" = "asc"): this {
|
|
138
|
+
this.#sorts.push({ path, direction });
|
|
139
|
+
return this;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
limit(n: number): this {
|
|
143
|
+
this.#limit = n;
|
|
144
|
+
return this;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
offset(n: number): this {
|
|
148
|
+
this.#offset = n;
|
|
149
|
+
return this;
|
|
150
|
+
}
|
|
151
|
+
|
|
99
152
|
async fetchAll(): Promise<InferModelInstance<TDef>[]> {
|
|
100
153
|
const collection = this.#collection;
|
|
101
154
|
if (!collection.loaded) await collection.load();
|
|
102
155
|
|
|
103
156
|
const definition = this.#definition;
|
|
104
157
|
const conditions = this.#queryBuilder.conditions;
|
|
105
|
-
|
|
158
|
+
let results: InferModelInstance<TDef>[] = [];
|
|
106
159
|
|
|
107
160
|
for (const pathId of collection.available) {
|
|
108
|
-
//
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
? definition.match({ id: pathId, meta: item.meta })
|
|
112
|
-
: pathId.startsWith(definition.prefix);
|
|
113
|
-
|
|
114
|
-
if (!matchesModel) continue;
|
|
161
|
+
// Delegate all matching logic to collection (handles _model meta, prefix, Base fallback)
|
|
162
|
+
const matchedDef = collection.findModelDefinition(pathId);
|
|
163
|
+
if (matchedDef?.name !== definition.name) continue;
|
|
115
164
|
|
|
116
165
|
const doc = collection.document(pathId);
|
|
117
166
|
const instance = createModelInstance(doc, definition, collection);
|
|
@@ -127,6 +176,44 @@ export class CollectionQuery<
|
|
|
127
176
|
}
|
|
128
177
|
}
|
|
129
178
|
|
|
179
|
+
if (this.#sorts.length > 0) {
|
|
180
|
+
results.sort((a, b) => {
|
|
181
|
+
for (const { path, direction } of this.#sorts) {
|
|
182
|
+
const aVal = getNestedValue(a, path);
|
|
183
|
+
const bVal = getNestedValue(b, path);
|
|
184
|
+
if (aVal === bVal) continue;
|
|
185
|
+
if (aVal == null) return direction === "asc" ? 1 : -1;
|
|
186
|
+
if (bVal == null) return direction === "asc" ? -1 : 1;
|
|
187
|
+
const cmp = aVal < bVal ? -1 : 1;
|
|
188
|
+
return direction === "asc" ? cmp : -cmp;
|
|
189
|
+
}
|
|
190
|
+
return 0;
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Apply pagination
|
|
195
|
+
if (this.#offset && this.#offset > 0) {
|
|
196
|
+
results = results.slice(this.#offset);
|
|
197
|
+
}
|
|
198
|
+
if (this.#limit !== undefined && this.#limit >= 0) {
|
|
199
|
+
results = results.slice(0, this.#limit);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Bind serialize options from include() so toJSON() includes relationships
|
|
203
|
+
if (this.#include.length > 0) {
|
|
204
|
+
const opts = this.serializeOptions;
|
|
205
|
+
for (const instance of results) {
|
|
206
|
+
const original = (instance as any).toJSON;
|
|
207
|
+
(instance as any).toJSON = (overrides?: SerializeOptions) => {
|
|
208
|
+
const merged = { ...opts, ...overrides };
|
|
209
|
+
if (opts.related && overrides?.related) {
|
|
210
|
+
merged.related = [...new Set([...opts.related, ...overrides.related])];
|
|
211
|
+
}
|
|
212
|
+
return original(merged);
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
130
217
|
return results;
|
|
131
218
|
}
|
|
132
219
|
|
package/src/query/index.ts
CHANGED
|
@@ -3,3 +3,10 @@ export { QueryBuilder } from "./query-builder";
|
|
|
3
3
|
export { operators } from "./operators";
|
|
4
4
|
export type { Operator } from "./operators";
|
|
5
5
|
export type { Condition } from "./query-builder";
|
|
6
|
+
export {
|
|
7
|
+
queryDSLSchema,
|
|
8
|
+
parseWhereClause,
|
|
9
|
+
parseSortClause,
|
|
10
|
+
executeQueryDSL,
|
|
11
|
+
} from "./query-dsl";
|
|
12
|
+
export type { QueryDSL } from "./query-dsl";
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { Collection } from "../collection";
|
|
3
|
+
import type { Condition } from "./query-builder";
|
|
4
|
+
import type { Operator } from "./operators";
|
|
5
|
+
import { resolveModelDef } from "../api/helpers.js";
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Operator mapping: $dsl → internal
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
const OPERATOR_MAP: Record<string, Operator> = {
|
|
12
|
+
$eq: "eq",
|
|
13
|
+
$neq: "neq",
|
|
14
|
+
$in: "in",
|
|
15
|
+
$notIn: "notIn",
|
|
16
|
+
$gt: "gt",
|
|
17
|
+
$lt: "lt",
|
|
18
|
+
$gte: "gte",
|
|
19
|
+
$lte: "lte",
|
|
20
|
+
$contains: "contains",
|
|
21
|
+
$startsWith: "startsWith",
|
|
22
|
+
$endsWith: "endsWith",
|
|
23
|
+
$regex: "regex",
|
|
24
|
+
$exists: "exists",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const VALID_OPERATORS = new Set(Object.keys(OPERATOR_MAP));
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Security helpers
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
const FORBIDDEN_PATH_SEGMENTS = new Set([
|
|
34
|
+
"__proto__",
|
|
35
|
+
"constructor",
|
|
36
|
+
"prototype",
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
const MAX_REGEX_LENGTH = 200;
|
|
40
|
+
|
|
41
|
+
function validatePath(path: string): void {
|
|
42
|
+
const segments = path.split(".");
|
|
43
|
+
for (const seg of segments) {
|
|
44
|
+
if (FORBIDDEN_PATH_SEGMENTS.has(seg)) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`Forbidden path segment "${seg}" in "${path}"`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Zod schema
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
const whereValueSchema: z.ZodType = z.union([
|
|
57
|
+
z.string(),
|
|
58
|
+
z.number(),
|
|
59
|
+
z.boolean(),
|
|
60
|
+
z.null(),
|
|
61
|
+
z.array(z.unknown()),
|
|
62
|
+
z.record(z.string(), z.unknown()),
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
const sortSchema = z.union([
|
|
66
|
+
z.record(z.string(), z.enum(["asc", "desc"])),
|
|
67
|
+
z.array(
|
|
68
|
+
z.object({
|
|
69
|
+
path: z.string(),
|
|
70
|
+
direction: z.enum(["asc", "desc"]).default("asc"),
|
|
71
|
+
}),
|
|
72
|
+
),
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
export const queryDSLSchema = z.object({
|
|
76
|
+
model: z.string(),
|
|
77
|
+
where: z.record(z.string(), whereValueSchema).optional(),
|
|
78
|
+
sort: sortSchema.optional(),
|
|
79
|
+
select: z.array(z.string()).optional(),
|
|
80
|
+
related: z.array(z.string()).optional(),
|
|
81
|
+
scopes: z.array(z.string()).optional(),
|
|
82
|
+
limit: z.number().int().min(0).optional(),
|
|
83
|
+
offset: z.number().int().min(0).optional(),
|
|
84
|
+
method: z
|
|
85
|
+
.enum(["fetchAll", "first", "last", "count"])
|
|
86
|
+
.default("fetchAll"),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
export type QueryDSL = z.infer<typeof queryDSLSchema>;
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Parser: where object → Condition[]
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
export function parseWhereClause(
|
|
96
|
+
where: Record<string, unknown>,
|
|
97
|
+
): Condition[] {
|
|
98
|
+
const conditions: Condition[] = [];
|
|
99
|
+
|
|
100
|
+
for (const [path, value] of Object.entries(where)) {
|
|
101
|
+
validatePath(path);
|
|
102
|
+
|
|
103
|
+
if (
|
|
104
|
+
value === null ||
|
|
105
|
+
typeof value === "string" ||
|
|
106
|
+
typeof value === "number" ||
|
|
107
|
+
typeof value === "boolean"
|
|
108
|
+
) {
|
|
109
|
+
// Literal → implicit $eq
|
|
110
|
+
conditions.push({ path, operator: "eq", value });
|
|
111
|
+
} else if (Array.isArray(value)) {
|
|
112
|
+
// Array → implicit $in
|
|
113
|
+
conditions.push({ path, operator: "in", value });
|
|
114
|
+
} else if (typeof value === "object" && value !== null) {
|
|
115
|
+
// Operator object: { "$gt": 5, "$lte": 10 }
|
|
116
|
+
for (const [opKey, opValue] of Object.entries(
|
|
117
|
+
value as Record<string, unknown>,
|
|
118
|
+
)) {
|
|
119
|
+
if (!VALID_OPERATORS.has(opKey)) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`Unknown operator: ${opKey}. Valid: ${[...VALID_OPERATORS].join(", ")}`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const operator = OPERATOR_MAP[opKey];
|
|
126
|
+
|
|
127
|
+
// Regex length guard
|
|
128
|
+
if (
|
|
129
|
+
operator === "regex" &&
|
|
130
|
+
typeof opValue === "string" &&
|
|
131
|
+
opValue.length > MAX_REGEX_LENGTH
|
|
132
|
+
) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`Regex pattern exceeds maximum length of ${MAX_REGEX_LENGTH} characters`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
conditions.push({ path, operator, value: opValue });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return conditions;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// Parser: sort clause → SortSpec[]
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
export function parseSortClause(
|
|
151
|
+
sort: unknown,
|
|
152
|
+
): Array<{ path: string; direction: "asc" | "desc" }> {
|
|
153
|
+
if (Array.isArray(sort)) {
|
|
154
|
+
return sort;
|
|
155
|
+
}
|
|
156
|
+
if (typeof sort === "object" && sort !== null) {
|
|
157
|
+
return Object.entries(sort as Record<string, string>).map(
|
|
158
|
+
([path, direction]) => ({
|
|
159
|
+
path,
|
|
160
|
+
direction: direction as "asc" | "desc",
|
|
161
|
+
}),
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
return [];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// Select helper
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
function applySelect(
|
|
172
|
+
instance: any,
|
|
173
|
+
select?: string[],
|
|
174
|
+
related?: string[],
|
|
175
|
+
): Record<string, unknown> {
|
|
176
|
+
const json = instance.toJSON({ related });
|
|
177
|
+
if (!select || select.length === 0) return json;
|
|
178
|
+
|
|
179
|
+
const filtered: Record<string, unknown> = {};
|
|
180
|
+
for (const key of select) {
|
|
181
|
+
if (key in json) {
|
|
182
|
+
filtered[key] = json[key];
|
|
183
|
+
} else if (key.startsWith("meta.") && json.meta) {
|
|
184
|
+
filtered[key] = json.meta[key.slice(5)];
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return filtered;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// Executor
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
export async function executeQueryDSL(
|
|
195
|
+
collection: Collection,
|
|
196
|
+
dsl: QueryDSL,
|
|
197
|
+
) {
|
|
198
|
+
const def = resolveModelDef(collection, dsl.model);
|
|
199
|
+
if (!def) {
|
|
200
|
+
throw new Error(`Unknown model: ${dsl.model}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
let q = collection.query(def);
|
|
204
|
+
|
|
205
|
+
// Apply scopes first
|
|
206
|
+
if (dsl.scopes) {
|
|
207
|
+
for (const name of dsl.scopes) {
|
|
208
|
+
q = q.scope(name);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Apply where conditions
|
|
213
|
+
if (dsl.where) {
|
|
214
|
+
const conditions = parseWhereClause(dsl.where);
|
|
215
|
+
for (const cond of conditions) {
|
|
216
|
+
q = q.where(cond.path, cond.operator, cond.value);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Apply sorts
|
|
221
|
+
if (dsl.sort) {
|
|
222
|
+
const sorts = parseSortClause(dsl.sort);
|
|
223
|
+
for (const { path, direction } of sorts) {
|
|
224
|
+
q = q.sort(path, direction);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Apply pagination
|
|
229
|
+
if (dsl.limit !== undefined) {
|
|
230
|
+
q = q.limit(dsl.limit);
|
|
231
|
+
}
|
|
232
|
+
if (dsl.offset !== undefined) {
|
|
233
|
+
q = q.offset(dsl.offset);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Execute based on method
|
|
237
|
+
switch (dsl.method) {
|
|
238
|
+
case "count":
|
|
239
|
+
return { count: await q.count() };
|
|
240
|
+
|
|
241
|
+
case "first": {
|
|
242
|
+
const result = await q.first();
|
|
243
|
+
return result ? applySelect(result, dsl.select, dsl.related) : null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
case "last": {
|
|
247
|
+
const result = await q.last();
|
|
248
|
+
return result ? applySelect(result, dsl.select, dsl.related) : null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
case "fetchAll":
|
|
252
|
+
default: {
|
|
253
|
+
const results = await q.fetchAll();
|
|
254
|
+
return results.map((instance: any) =>
|
|
255
|
+
applySelect(instance, dsl.select, dsl.related),
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|