contentbase 0.0.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.
Files changed (89) hide show
  1. package/README.md +460 -0
  2. package/bun.lock +473 -0
  3. package/examples/sdlc-queries.ts +161 -0
  4. package/package.json +41 -0
  5. package/showcases/national-parks/models.ts +74 -0
  6. package/showcases/national-parks/parks/acadia.mdx +40 -0
  7. package/showcases/national-parks/parks/yosemite.mdx +44 -0
  8. package/showcases/national-parks/parks/zion.mdx +44 -0
  9. package/showcases/national-parks/queries.ts +103 -0
  10. package/showcases/national-parks/trails/angels-landing.mdx +19 -0
  11. package/showcases/national-parks/trails/cathedral-lakes.mdx +19 -0
  12. package/showcases/national-parks/trails/half-dome.mdx +19 -0
  13. package/showcases/national-parks/trails/jordan-pond-path.mdx +19 -0
  14. package/showcases/national-parks/trails/mist-trail.mdx +19 -0
  15. package/showcases/national-parks/trails/observation-point.mdx +19 -0
  16. package/showcases/national-parks/trails/precipice-trail.mdx +19 -0
  17. package/showcases/national-parks/trails/the-narrows.mdx +19 -0
  18. package/showcases/recipes/cuisines/chinese.mdx +28 -0
  19. package/showcases/recipes/cuisines/italian.mdx +32 -0
  20. package/showcases/recipes/cuisines/mexican.mdx +28 -0
  21. package/showcases/recipes/models.ts +77 -0
  22. package/showcases/recipes/queries.ts +89 -0
  23. package/showcases/recipes/recipes/chinese/egg-fried-rice.mdx +43 -0
  24. package/showcases/recipes/recipes/chinese/mapo-tofu.mdx +47 -0
  25. package/showcases/recipes/recipes/italian/bruschetta.mdx +38 -0
  26. package/showcases/recipes/recipes/italian/cacio-e-pepe.mdx +39 -0
  27. package/showcases/recipes/recipes/italian/tiramisu.mdx +43 -0
  28. package/showcases/recipes/recipes/mexican/chicken-tinga.mdx +44 -0
  29. package/showcases/recipes/recipes/mexican/guacamole.mdx +39 -0
  30. package/showcases/vinyl-collection/albums/bitches-brew.mdx +36 -0
  31. package/showcases/vinyl-collection/albums/i-put-a-spell-on-you.mdx +35 -0
  32. package/showcases/vinyl-collection/albums/in-rainbows.mdx +35 -0
  33. package/showcases/vinyl-collection/albums/kind-of-blue.mdx +32 -0
  34. package/showcases/vinyl-collection/albums/ok-computer.mdx +37 -0
  35. package/showcases/vinyl-collection/albums/wild-is-the-wind.mdx +35 -0
  36. package/showcases/vinyl-collection/artists/miles-davis.mdx +27 -0
  37. package/showcases/vinyl-collection/artists/nina-simone.mdx +26 -0
  38. package/showcases/vinyl-collection/artists/radiohead.mdx +27 -0
  39. package/showcases/vinyl-collection/models.ts +73 -0
  40. package/showcases/vinyl-collection/queries.ts +87 -0
  41. package/src/ast-query.ts +132 -0
  42. package/src/cli/commands/action.ts +44 -0
  43. package/src/cli/commands/create.ts +59 -0
  44. package/src/cli/commands/export.ts +24 -0
  45. package/src/cli/commands/init.ts +75 -0
  46. package/src/cli/commands/inspect.ts +46 -0
  47. package/src/cli/commands/validate.ts +75 -0
  48. package/src/cli/index.ts +20 -0
  49. package/src/cli/load-collection.ts +53 -0
  50. package/src/collection.ts +399 -0
  51. package/src/define-model.ts +80 -0
  52. package/src/document.ts +468 -0
  53. package/src/index.ts +47 -0
  54. package/src/model-instance.ts +227 -0
  55. package/src/node-shortcuts.ts +87 -0
  56. package/src/parse.ts +123 -0
  57. package/src/query/collection-query.ts +149 -0
  58. package/src/query/index.ts +5 -0
  59. package/src/query/operators.ts +37 -0
  60. package/src/query/query-builder.ts +109 -0
  61. package/src/relationships/belongs-to.ts +50 -0
  62. package/src/relationships/has-many.ts +136 -0
  63. package/src/relationships/index.ts +57 -0
  64. package/src/relationships/types.ts +7 -0
  65. package/src/section.ts +29 -0
  66. package/src/types.ts +221 -0
  67. package/src/utils/index.ts +11 -0
  68. package/src/utils/inflect.ts +82 -0
  69. package/src/utils/normalize-headings.ts +31 -0
  70. package/src/utils/parse-table.ts +30 -0
  71. package/src/utils/read-directory.ts +35 -0
  72. package/src/utils/stringify-ast.ts +9 -0
  73. package/src/validator.ts +52 -0
  74. package/test/ast-query.test.ts +128 -0
  75. package/test/collection.test.ts +99 -0
  76. package/test/define-model.test.ts +78 -0
  77. package/test/document.test.ts +225 -0
  78. package/test/fixtures/sdlc/epics/authentication.mdx +42 -0
  79. package/test/fixtures/sdlc/epics/searching-and-browsing.mdx +21 -0
  80. package/test/fixtures/sdlc/models.ts +89 -0
  81. package/test/fixtures/sdlc/stories/authentication/a-user-should-be-able-to-register.mdx +20 -0
  82. package/test/helpers.ts +21 -0
  83. package/test/model-instance.test.ts +197 -0
  84. package/test/query.test.ts +167 -0
  85. package/test/relationships.test.ts +84 -0
  86. package/test/section.test.ts +99 -0
  87. package/test/validator.test.ts +62 -0
  88. package/tsconfig.json +18 -0
  89. package/vitest.config.ts +11 -0
@@ -0,0 +1,227 @@
1
+ import { z } from "zod";
2
+ import type {
3
+ ModelDefinition,
4
+ InferModelInstance,
5
+ SectionDefinition,
6
+ HasManyDefinition,
7
+ BelongsToDefinition,
8
+ ValidationResult,
9
+ SerializeOptions,
10
+ } from "./types";
11
+ import type { Document } from "./document";
12
+ import type { Collection } from "./collection";
13
+ import { HasManyRelationship } from "./relationships/has-many";
14
+ import { BelongsToRelationship } from "./relationships/belongs-to";
15
+
16
+ /**
17
+ * Creates a model instance from a document and its model definition.
18
+ *
19
+ * This is the central factory function. Every typed model instance
20
+ * in contentbase is created here.
21
+ */
22
+ export function createModelInstance<
23
+ TDef extends ModelDefinition<any, any, any, any, any>,
24
+ >(
25
+ document: Document,
26
+ definition: TDef,
27
+ collection: Collection
28
+ ): InferModelInstance<TDef> {
29
+ // ─── Meta: merge defaults, parse with Zod ───
30
+ const rawMeta = { ...(definition.defaults ?? {}), ...document.meta };
31
+ let meta: any;
32
+ try {
33
+ meta = definition.meta.parse(rawMeta);
34
+ } catch (e) {
35
+ // If parsing fails, use raw meta so the instance can still be created
36
+ // Validation will catch the errors later
37
+ meta = rawMeta;
38
+ }
39
+
40
+ // ─── Sections: lazy extraction via defineProperty ───
41
+ const sections = {} as Record<string, unknown>;
42
+ if (definition.sections) {
43
+ for (const [key, sectionDef] of Object.entries(definition.sections)) {
44
+ const sd = sectionDef as SectionDefinition<unknown>;
45
+ let cached: { value: unknown } | null = null;
46
+ Object.defineProperty(sections, key, {
47
+ get() {
48
+ if (!cached) {
49
+ const sectionQuery = document.querySection(sd.heading);
50
+ cached = { value: sd.extract(sectionQuery) };
51
+ }
52
+ return cached.value;
53
+ },
54
+ enumerable: true,
55
+ configurable: true,
56
+ });
57
+ }
58
+ }
59
+
60
+ // ─── Relationships: create accessor objects ───
61
+ const relationships = {} as Record<string, unknown>;
62
+ if (definition.relationships) {
63
+ for (const [key, relDef] of Object.entries(definition.relationships)) {
64
+ if ((relDef as any).type === "hasMany") {
65
+ const hm = relDef as HasManyDefinition<any>;
66
+ relationships[key] = new HasManyRelationship(
67
+ document,
68
+ collection,
69
+ hm,
70
+ createModelInstance
71
+ );
72
+ } else if ((relDef as any).type === "belongsTo") {
73
+ const bt = relDef as BelongsToDefinition<any>;
74
+ relationships[key] = new BelongsToRelationship(
75
+ document,
76
+ collection,
77
+ bt,
78
+ createModelInstance
79
+ );
80
+ }
81
+ }
82
+ }
83
+
84
+ // ─── Computed: lazy getters ───
85
+ const computed = {} as Record<string, unknown>;
86
+ const selfProxy = {
87
+ meta,
88
+ sections,
89
+ relationships,
90
+ document,
91
+ id: document.id,
92
+ title: document.title,
93
+ slug: document.slug,
94
+ };
95
+
96
+ if (definition.computed) {
97
+ for (const [key, fn] of Object.entries(definition.computed)) {
98
+ Object.defineProperty(computed, key, {
99
+ get() {
100
+ return (fn as Function)(selfProxy);
101
+ },
102
+ enumerable: true,
103
+ configurable: true,
104
+ });
105
+ }
106
+ }
107
+
108
+ // ─── Validation ───
109
+ const errors = new Map<string, z.ZodIssue>();
110
+
111
+ async function validate(): Promise<ValidationResult> {
112
+ errors.clear();
113
+
114
+ // Validate meta
115
+ const metaResult = definition.meta.safeParse(rawMeta);
116
+ if (!metaResult.success) {
117
+ for (const issue of metaResult.error.issues) {
118
+ errors.set(issue.path.join(".") || "meta", issue);
119
+ }
120
+ }
121
+
122
+ // Validate sections that have schemas
123
+ if (definition.sections) {
124
+ for (const [key, sd] of Object.entries(definition.sections)) {
125
+ const sectionDef = sd as SectionDefinition<unknown>;
126
+ if (sectionDef.schema) {
127
+ const sectionData = (sections as any)[key];
128
+ const sResult = sectionDef.schema.safeParse(sectionData);
129
+ if (!sResult.success) {
130
+ for (const issue of sResult.error.issues) {
131
+ errors.set(
132
+ `sections.${key}.${issue.path.join(".")}`,
133
+ issue
134
+ );
135
+ }
136
+ }
137
+ }
138
+ }
139
+ }
140
+
141
+ return {
142
+ valid: errors.size === 0,
143
+ errors: Array.from(errors.values()),
144
+ };
145
+ }
146
+
147
+ // ─── toJSON ───
148
+ function toJSON(
149
+ options: SerializeOptions = {}
150
+ ): Record<string, unknown> {
151
+ const json: Record<string, unknown> = {
152
+ id: document.id,
153
+ title: document.title,
154
+ meta,
155
+ };
156
+
157
+ // Include requested sections
158
+ if (options.sections) {
159
+ for (const key of options.sections) {
160
+ if (key in sections) {
161
+ json[key] = (sections as any)[key];
162
+ }
163
+ }
164
+ }
165
+
166
+ // Include requested computed values
167
+ if (options.computed) {
168
+ for (const key of options.computed) {
169
+ if (key in computed) {
170
+ json[key] = (computed as any)[key];
171
+ }
172
+ }
173
+ }
174
+
175
+ // Include requested relationships
176
+ if (options.related) {
177
+ for (const key of options.related) {
178
+ const rel = (relationships as any)[key];
179
+ if (!rel) continue;
180
+
181
+ if ("fetchAll" in rel) {
182
+ json[key] = rel.fetchAll().map((inst: any) => inst.toJSON());
183
+ } else if ("fetch" in rel) {
184
+ json[key] = rel.fetch().toJSON();
185
+ }
186
+ }
187
+ }
188
+
189
+ return json;
190
+ }
191
+
192
+ // ─── Assemble the instance ───
193
+ const instance = {
194
+ id: document.id,
195
+ get title() {
196
+ return document.title;
197
+ },
198
+ get slug() {
199
+ return document.slug;
200
+ },
201
+ document,
202
+ collection,
203
+ meta,
204
+ sections,
205
+ relationships,
206
+ computed,
207
+ errors,
208
+ get hasErrors() {
209
+ return errors.size > 0;
210
+ },
211
+ validate,
212
+ toJSON,
213
+ async runAction(
214
+ name: string,
215
+ opts: Record<string, unknown> = {}
216
+ ) {
217
+ const actionFn = collection.actions.get(name);
218
+ if (!actionFn) throw new Error(`Action "${name}" not found`);
219
+ return actionFn(collection, instance, opts);
220
+ },
221
+ async save(opts = {}) {
222
+ await document.save(opts);
223
+ },
224
+ };
225
+
226
+ return instance as InferModelInstance<TDef>;
227
+ }
@@ -0,0 +1,87 @@
1
+ import { parseTable } from "./utils/parse-table";
2
+ import type { AstQuery } from "./ast-query";
3
+ import type { Content, Heading } from "mdast";
4
+
5
+ /**
6
+ * Convenience getters for common node queries on a document.
7
+ */
8
+ export class NodeShortcuts {
9
+ #astQuery: AstQuery;
10
+
11
+ constructor(astQuery: AstQuery) {
12
+ this.#astQuery = astQuery;
13
+ }
14
+
15
+ get first(): Content | undefined {
16
+ return this.#astQuery.ast.children[0] as Content | undefined;
17
+ }
18
+
19
+ get last(): Content | undefined {
20
+ const children = this.#astQuery.ast.children;
21
+ return children[children.length - 1] as Content | undefined;
22
+ }
23
+
24
+ get headings(): Heading[] {
25
+ return this.#astQuery.selectAll("heading") as Heading[];
26
+ }
27
+
28
+ get firstHeading(): Heading | undefined {
29
+ return this.headings[0];
30
+ }
31
+
32
+ get secondHeading(): Heading | undefined {
33
+ return this.headings[1];
34
+ }
35
+
36
+ get lastHeading(): Heading | undefined {
37
+ return this.headings[this.headings.length - 1];
38
+ }
39
+
40
+ get leadingElementsAfterTitle(): Content[] {
41
+ const { firstHeading, secondHeading } = this;
42
+ if (!firstHeading) return [];
43
+ if (secondHeading) {
44
+ return this.#astQuery.findBetween(firstHeading, secondHeading);
45
+ }
46
+ return this.#astQuery.findAllAfter(firstHeading);
47
+ }
48
+
49
+ get headingsByDepth(): Record<number, Heading[]> {
50
+ return this.headings.reduce(
51
+ (memo, heading) => {
52
+ memo[heading.depth] = memo[heading.depth] || [];
53
+ memo[heading.depth].push(heading);
54
+ return memo;
55
+ },
56
+ {} as Record<number, Heading[]>
57
+ );
58
+ }
59
+
60
+ get links(): Content[] {
61
+ return this.#astQuery.selectAll("link");
62
+ }
63
+
64
+ get tables(): Content[] {
65
+ return this.#astQuery.selectAll("table");
66
+ }
67
+
68
+ get tablesAsData(): Record<string, string>[][] {
69
+ return this.tables.map((table) => parseTable(table));
70
+ }
71
+
72
+ get paragraphs(): Content[] {
73
+ return this.#astQuery.selectAll("paragraph");
74
+ }
75
+
76
+ get lists(): Content[] {
77
+ return this.#astQuery.selectAll("list");
78
+ }
79
+
80
+ get codeBlocks(): Content[] {
81
+ return this.#astQuery.selectAll("code");
82
+ }
83
+
84
+ get images(): Content[] {
85
+ return this.#astQuery.selectAll("image");
86
+ }
87
+ }
package/src/parse.ts ADDED
@@ -0,0 +1,123 @@
1
+ import fs from "fs/promises";
2
+ import matter from "gray-matter";
3
+ import { unified } from "unified";
4
+ import remarkParse from "remark-parse";
5
+ import remarkGfm from "remark-gfm";
6
+ import { toString } from "mdast-util-to-string";
7
+ import { AstQuery } from "./ast-query";
8
+ import { NodeShortcuts } from "./node-shortcuts";
9
+ import { stringifyAst } from "./utils/stringify-ast";
10
+ import type { Root, Content, RootContent } from "mdast";
11
+
12
+ const processor = unified().use(remarkParse).use(remarkGfm);
13
+
14
+ export interface ParsedDocument {
15
+ /** YAML frontmatter key/values */
16
+ meta: Record<string, unknown>;
17
+ /** Markdown content (without frontmatter) */
18
+ content: string;
19
+ /** The MDAST root node */
20
+ ast: Root;
21
+ /** Queryable AST wrapper */
22
+ astQuery: AstQuery;
23
+ /** Convenience node accessors */
24
+ nodes: NodeShortcuts;
25
+ /** First heading text, or empty string */
26
+ title: string;
27
+ /** Stringify an AST back to markdown */
28
+ stringify(ast?: Root): string;
29
+ /** Extract a section by heading text */
30
+ extractSection(heading: string | Content): Content[];
31
+ /** Get a queryable AstQuery scoped to a section */
32
+ querySection(heading: string | Content): AstQuery;
33
+ }
34
+
35
+ /**
36
+ * Parse a markdown/MDX file or raw string into a queryable document.
37
+ *
38
+ * @param input - A file path (.md/.mdx) or raw markdown string
39
+ * @returns A ParsedDocument with AST query capabilities
40
+ *
41
+ * @example
42
+ * ```ts
43
+ * import { parse } from "contentbase";
44
+ *
45
+ * const doc = await parse("./content/my-post.mdx");
46
+ * doc.meta // frontmatter
47
+ * doc.astQuery.selectAll("heading")
48
+ * doc.nodes.tables
49
+ * doc.querySection("Introduction").selectAll("paragraph")
50
+ * ```
51
+ */
52
+ export async function parse(input: string): Promise<ParsedDocument> {
53
+ let raw: string;
54
+
55
+ if (looksLikeFilePath(input)) {
56
+ raw = await fs.readFile(input, "utf-8");
57
+ } else {
58
+ raw = input;
59
+ }
60
+
61
+ const { data: meta, content } = matter(raw);
62
+ const ast = processor.parse(content);
63
+ const astQuery = new AstQuery(ast);
64
+ const nodes = new NodeShortcuts(astQuery);
65
+
66
+ const firstHeading = astQuery.select("heading");
67
+ const title = firstHeading ? toString(firstHeading) : "";
68
+
69
+ function extractSection(startHeading: string | Content): Content[] {
70
+ let heading: Content | undefined;
71
+ if (typeof startHeading === "string") {
72
+ heading = astQuery.findHeadingByText(startHeading) as Content | undefined;
73
+ } else {
74
+ heading = startHeading;
75
+ }
76
+ if (!heading) {
77
+ throw new Error(
78
+ `Heading not found: ${typeof startHeading === "string" ? startHeading : toString(startHeading)}`
79
+ );
80
+ }
81
+
82
+ const endHeading = astQuery.findNextSiblingHeadingTo(heading as any);
83
+ const sectionNodes = endHeading
84
+ ? astQuery.findBetween(heading, endHeading)
85
+ : astQuery.findAllAfter(heading);
86
+ return [heading, ...sectionNodes];
87
+ }
88
+
89
+ function querySection(startHeading: string | Content): AstQuery {
90
+ let children: Content[] = [];
91
+ try {
92
+ children = extractSection(startHeading).slice(1);
93
+ } catch {
94
+ // Section not found: return empty query
95
+ }
96
+ return new AstQuery({
97
+ type: "root",
98
+ children: children as RootContent[],
99
+ });
100
+ }
101
+
102
+ return {
103
+ meta,
104
+ content,
105
+ ast,
106
+ astQuery,
107
+ nodes,
108
+ title,
109
+ stringify: (tree: Root = ast) => stringifyAst(tree),
110
+ extractSection,
111
+ querySection,
112
+ };
113
+ }
114
+
115
+ function looksLikeFilePath(input: string): boolean {
116
+ // If it contains a newline, it's raw markdown
117
+ if (input.includes("\n")) return false;
118
+ // If it ends with a markdown extension, it's a path
119
+ if (/\.mdx?$/i.test(input)) return true;
120
+ // If it starts with . or / or ~, treat as path
121
+ if (/^[.\/~]/.test(input)) return true;
122
+ return false;
123
+ }
@@ -0,0 +1,149 @@
1
+ import { QueryBuilder } from "./query-builder";
2
+ import { operators } from "./operators";
3
+ import { createModelInstance } from "../model-instance";
4
+ import type { Collection } from "../collection";
5
+ import type { ModelDefinition, InferModelInstance } from "../types";
6
+
7
+ /**
8
+ * CollectionQuery is a typed query builder for a specific model type.
9
+ * Results are typed as InferModelInstance<TDef>[].
10
+ *
11
+ * Usage:
12
+ * const results = await collection
13
+ * .query(Epic)
14
+ * .where("meta.priority", "high")
15
+ * .fetchAll();
16
+ */
17
+ export class CollectionQuery<
18
+ TDef extends ModelDefinition<any, any, any, any, any>,
19
+ > {
20
+ #collection: Collection;
21
+ #definition: TDef;
22
+ #queryBuilder: QueryBuilder;
23
+
24
+ constructor(collection: Collection, definition: TDef) {
25
+ this.#collection = collection;
26
+ this.#definition = definition;
27
+ this.#queryBuilder = new QueryBuilder();
28
+ }
29
+
30
+ where(
31
+ pathOrObject: string | Record<string, unknown>,
32
+ operatorOrValue?: any,
33
+ value?: any
34
+ ): this {
35
+ this.#queryBuilder.where(pathOrObject, operatorOrValue, value);
36
+ return this;
37
+ }
38
+
39
+ whereIn(path: string, values: unknown[]): this {
40
+ this.#queryBuilder.whereIn(path, values);
41
+ return this;
42
+ }
43
+
44
+ whereNotIn(path: string, values: unknown[]): this {
45
+ this.#queryBuilder.whereNotIn(path, values);
46
+ return this;
47
+ }
48
+
49
+ whereGt(path: string, value: unknown): this {
50
+ this.#queryBuilder.whereGt(path, value);
51
+ return this;
52
+ }
53
+
54
+ whereLt(path: string, value: unknown): this {
55
+ this.#queryBuilder.whereLt(path, value);
56
+ return this;
57
+ }
58
+
59
+ whereGte(path: string, value: unknown): this {
60
+ this.#queryBuilder.whereGte(path, value);
61
+ return this;
62
+ }
63
+
64
+ whereLte(path: string, value: unknown): this {
65
+ this.#queryBuilder.whereLte(path, value);
66
+ return this;
67
+ }
68
+
69
+ whereContains(path: string, value: string): this {
70
+ this.#queryBuilder.whereContains(path, value);
71
+ return this;
72
+ }
73
+
74
+ whereStartsWith(path: string, value: string): this {
75
+ this.#queryBuilder.whereStartsWith(path, value);
76
+ return this;
77
+ }
78
+
79
+ whereEndsWith(path: string, value: string): this {
80
+ this.#queryBuilder.whereEndsWith(path, value);
81
+ return this;
82
+ }
83
+
84
+ whereRegex(path: string, pattern: RegExp | string): this {
85
+ this.#queryBuilder.whereRegex(path, pattern);
86
+ return this;
87
+ }
88
+
89
+ whereExists(path: string): this {
90
+ this.#queryBuilder.whereExists(path);
91
+ return this;
92
+ }
93
+
94
+ whereNotExists(path: string): this {
95
+ this.#queryBuilder.whereNotExists(path);
96
+ return this;
97
+ }
98
+
99
+ async fetchAll(): Promise<InferModelInstance<TDef>[]> {
100
+ const collection = this.#collection;
101
+ if (!collection.loaded) await collection.load();
102
+
103
+ const definition = this.#definition;
104
+ const conditions = this.#queryBuilder.conditions;
105
+ const results: InferModelInstance<TDef>[] = [];
106
+
107
+ for (const pathId of collection.available) {
108
+ // Filter by model type BEFORE creating instances (fixes original perf bug)
109
+ const item = collection.items.get(pathId)!;
110
+ const matchesModel = definition.match
111
+ ? definition.match({ id: pathId, meta: item.meta })
112
+ : pathId.startsWith(definition.prefix);
113
+
114
+ if (!matchesModel) continue;
115
+
116
+ const doc = collection.document(pathId);
117
+ const instance = createModelInstance(doc, definition, collection);
118
+
119
+ // Apply query conditions
120
+ const passesAll = conditions.every((cond) => {
121
+ const actual = getNestedValue(instance, cond.path);
122
+ return operators[cond.operator](actual, cond.value);
123
+ });
124
+
125
+ if (passesAll) {
126
+ results.push(instance);
127
+ }
128
+ }
129
+
130
+ return results;
131
+ }
132
+
133
+ async first(): Promise<InferModelInstance<TDef> | undefined> {
134
+ return (await this.fetchAll())[0];
135
+ }
136
+
137
+ async last(): Promise<InferModelInstance<TDef> | undefined> {
138
+ const all = await this.fetchAll();
139
+ return all[all.length - 1];
140
+ }
141
+
142
+ async count(): Promise<number> {
143
+ return (await this.fetchAll()).length;
144
+ }
145
+ }
146
+
147
+ function getNestedValue(obj: any, path: string): unknown {
148
+ return path.split(".").reduce((acc, key) => acc?.[key], obj);
149
+ }
@@ -0,0 +1,5 @@
1
+ export { CollectionQuery } from "./collection-query";
2
+ export { QueryBuilder } from "./query-builder";
3
+ export { operators } from "./operators";
4
+ export type { Operator } from "./operators";
5
+ export type { Condition } from "./query-builder";
@@ -0,0 +1,37 @@
1
+ export type Operator =
2
+ | "eq"
3
+ | "neq"
4
+ | "in"
5
+ | "notIn"
6
+ | "gt"
7
+ | "lt"
8
+ | "gte"
9
+ | "lte"
10
+ | "contains"
11
+ | "startsWith"
12
+ | "endsWith"
13
+ | "regex"
14
+ | "exists";
15
+
16
+ export const operators: Record<
17
+ Operator,
18
+ (actual: any, expected: any) => boolean
19
+ > = {
20
+ eq: (a, b) => a === b || JSON.stringify(a) === JSON.stringify(b),
21
+ neq: (a, b) => !operators.eq(a, b),
22
+ in: (a, b) => Array.isArray(b) && b.includes(a),
23
+ notIn: (a, b) => Array.isArray(b) && !b.includes(a),
24
+ gt: (a, b) => a > b,
25
+ lt: (a, b) => a < b,
26
+ gte: (a, b) => a >= b,
27
+ lte: (a, b) => a <= b,
28
+ contains: (a, b) => typeof a === "string" && a.includes(b),
29
+ startsWith: (a, b) => typeof a === "string" && a.startsWith(b),
30
+ endsWith: (a, b) => typeof a === "string" && a.endsWith(b),
31
+ regex: (a, b) =>
32
+ b instanceof RegExp
33
+ ? b.test(String(a))
34
+ : new RegExp(b).test(String(a)),
35
+ exists: (a, b) =>
36
+ b ? a !== undefined && a !== null : a === undefined || a === null,
37
+ };