contentbase 0.0.2 → 0.0.4

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 (85) hide show
  1. package/.mcp.json +9 -0
  2. package/CLI.md +593 -0
  3. package/MCP-DESCRIPTIONS-REVIEW.md +122 -0
  4. package/MCP-SERVER-SPEC.md +453 -0
  5. package/PRIMER.md +540 -0
  6. package/README.md +289 -13
  7. package/bun.lock +43 -4
  8. package/dist/cnotes +0 -0
  9. package/docs/README.md +110 -0
  10. package/docs/TABLE-OF-CONTENTS.md +7 -0
  11. package/docs/models.ts +38 -0
  12. package/models.ts +38 -0
  13. package/package.json +12 -3
  14. package/public/web-demo/index.html +813 -0
  15. package/showcases/node_modules/.cache/.repl_history +3 -0
  16. package/showcases/vinyl-collection/artists/nina-simone.mdx +1 -1
  17. package/src/__tests__/semantic-search.integration.test.ts +284 -0
  18. package/src/api/endpoints/actions.ts +34 -0
  19. package/src/api/endpoints/doc.ts +187 -0
  20. package/src/api/endpoints/docs-index.ts +26 -0
  21. package/src/api/endpoints/document.ts +171 -0
  22. package/src/api/endpoints/documents.ts +71 -0
  23. package/src/api/endpoints/inspect.ts +16 -0
  24. package/src/api/endpoints/models.ts +10 -0
  25. package/src/api/endpoints/query.ts +88 -0
  26. package/src/api/endpoints/root.ts +7 -0
  27. package/src/api/endpoints/search-reindex.ts +65 -0
  28. package/src/api/endpoints/search-status.ts +55 -0
  29. package/src/api/endpoints/search.ts +104 -0
  30. package/src/api/endpoints/semantic-search.ts +120 -0
  31. package/src/api/endpoints/text-search.ts +63 -0
  32. package/src/api/endpoints/validate.ts +34 -0
  33. package/src/api/helpers.ts +97 -0
  34. package/src/base-model.ts +12 -0
  35. package/src/cli/commands/action.ts +82 -44
  36. package/src/cli/commands/console.ts +124 -0
  37. package/src/cli/commands/create.ts +179 -53
  38. package/src/cli/commands/embed.ts +323 -0
  39. package/src/cli/commands/export.ts +58 -24
  40. package/src/cli/commands/extract.ts +174 -0
  41. package/src/cli/commands/help.ts +81 -0
  42. package/src/cli/commands/index.ts +17 -0
  43. package/src/cli/commands/init.ts +72 -48
  44. package/src/cli/commands/inspect.ts +56 -46
  45. package/src/cli/commands/mcp.ts +1219 -0
  46. package/src/cli/commands/search.ts +285 -0
  47. package/src/cli/commands/serve.ts +348 -0
  48. package/src/cli/commands/summary.ts +60 -0
  49. package/src/cli/commands/teach.ts +86 -0
  50. package/src/cli/commands/text-search.ts +134 -0
  51. package/src/cli/commands/validate.ts +126 -64
  52. package/src/cli/index.ts +88 -19
  53. package/src/cli/load-collection.ts +144 -17
  54. package/src/cli/registry.ts +28 -0
  55. package/src/collection.ts +361 -10
  56. package/src/define-model.ts +101 -4
  57. package/src/document.ts +47 -1
  58. package/src/extract-sections.ts +1 -1
  59. package/src/index.ts +14 -2
  60. package/src/model-instance.ts +35 -5
  61. package/src/node-shortcuts.ts +1 -1
  62. package/src/query/collection-query.ts +96 -9
  63. package/src/query/index.ts +7 -0
  64. package/src/query/query-dsl.ts +259 -0
  65. package/src/relationships/has-many.ts +39 -0
  66. package/src/relationships/index.ts +7 -2
  67. package/src/section.ts +2 -0
  68. package/src/types.ts +23 -1
  69. package/src/utils/index.ts +1 -0
  70. package/src/utils/match-pattern.ts +65 -0
  71. package/src/validator.ts +18 -1
  72. package/test/collection.test.ts +118 -2
  73. package/test/fixtures/sdlc/MODELS.md +106 -0
  74. package/test/fixtures/sdlc/SKILL.md +404 -0
  75. package/test/fixtures/sdlc/index.ts +9 -0
  76. package/test/fixtures/sdlc/models.ts +8 -6
  77. package/test/fixtures/sdlc/stories/authentication/a-user-should-be-able-to-login.mdx +20 -0
  78. package/test/fixtures/sdlc/templates/epic.md +23 -0
  79. package/test/fixtures/sdlc/templates/story.md +19 -0
  80. package/test/pattern.test.ts +191 -0
  81. package/test/query-dsl.test.ts +431 -0
  82. package/test/query.test.ts +29 -0
  83. package/test/relationships.test.ts +130 -0
  84. package/test/section.test.ts +61 -0
  85. package/test/table-of-contents.test.ts +49 -5
@@ -0,0 +1,191 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { matchPattern, matchPatterns } from "../src/utils/match-pattern";
3
+ import { defineModel, z } from "../src/index";
4
+ import { createModelInstance } from "../src/model-instance";
5
+ import { validateDocument } from "../src/validator";
6
+ import { Collection } from "../src/collection";
7
+ import { createTestCollection } from "./helpers";
8
+
9
+ describe("matchPattern", () => {
10
+ it("extracts named params from matching path", () => {
11
+ const result = matchPattern("plans/:project/:slug", "plans/acme/launch");
12
+ expect(result).toEqual({ project: "acme", slug: "launch" });
13
+ });
14
+
15
+ it("returns null on literal segment mismatch", () => {
16
+ const result = matchPattern("plans/:project/:slug", "stories/acme/launch");
17
+ expect(result).toBeNull();
18
+ });
19
+
20
+ it("returns null on segment count mismatch (too few)", () => {
21
+ const result = matchPattern("plans/:project/:slug", "plans/acme");
22
+ expect(result).toBeNull();
23
+ });
24
+
25
+ it("returns null on segment count mismatch (too many)", () => {
26
+ const result = matchPattern("plans/:project/:slug", "plans/acme/launch/extra");
27
+ expect(result).toBeNull();
28
+ });
29
+
30
+ it("matches literal-only pattern", () => {
31
+ const result = matchPattern("plans/acme/launch", "plans/acme/launch");
32
+ expect(result).toEqual({});
33
+ });
34
+
35
+ it("returns null for literal-only mismatch", () => {
36
+ const result = matchPattern("plans/acme/launch", "plans/acme/other");
37
+ expect(result).toBeNull();
38
+ });
39
+
40
+ it("handles single-segment patterns", () => {
41
+ const result = matchPattern(":slug", "my-doc");
42
+ expect(result).toEqual({ slug: "my-doc" });
43
+ });
44
+
45
+ it("handles leading/trailing slashes gracefully", () => {
46
+ const result = matchPattern("/plans/:slug/", "/plans/launch/");
47
+ expect(result).toEqual({ slug: "launch" });
48
+ });
49
+ });
50
+
51
+ describe("matchPatterns", () => {
52
+ it("accepts a single pattern string", () => {
53
+ const result = matchPatterns("plans/:project/:slug", "plans/acme/launch");
54
+ expect(result).toEqual({ project: "acme", slug: "launch" });
55
+ });
56
+
57
+ it("returns first matching pattern from array", () => {
58
+ const result = matchPatterns(
59
+ ["docs/:category/:slug", "plans/:project/:slug"],
60
+ "plans/acme/launch"
61
+ );
62
+ expect(result).toEqual({ project: "acme", slug: "launch" });
63
+ });
64
+
65
+ it("skips non-matching patterns", () => {
66
+ const result = matchPatterns(
67
+ ["docs/:category/:slug", "plans/:project/:slug"],
68
+ "plans/acme/launch"
69
+ );
70
+ // First pattern doesn't match (docs != plans), second does
71
+ expect(result).toEqual({ project: "acme", slug: "launch" });
72
+ });
73
+
74
+ it("returns null when no patterns match", () => {
75
+ const result = matchPatterns(
76
+ ["docs/:slug", "plans/:slug"],
77
+ "stories/my-story"
78
+ );
79
+ expect(result).toBeNull();
80
+ });
81
+
82
+ it("returns first match when multiple patterns could match", () => {
83
+ const result = matchPatterns(
84
+ ["things/:a/:b", "things/:x/:y"],
85
+ "things/foo/bar"
86
+ );
87
+ expect(result).toEqual({ a: "foo", b: "bar" });
88
+ });
89
+ });
90
+
91
+ describe("pattern integration", () => {
92
+ let collection: Collection;
93
+
94
+ beforeEach(async () => {
95
+ collection = await createTestCollection();
96
+ });
97
+
98
+ const Plan = defineModel("Plan", {
99
+ prefix: "plans",
100
+ pattern: "plans/:project/:slug",
101
+ meta: z.object({
102
+ project: z.string(),
103
+ status: z.enum(["draft", "active"]).default("draft"),
104
+ }),
105
+ });
106
+
107
+ it("infers meta from path pattern via createModelInstance", () => {
108
+ const doc = collection.createDocument({
109
+ id: "plans/acme/launch",
110
+ content: "# Launch Plan\n",
111
+ meta: {},
112
+ });
113
+ const instance = createModelInstance(doc, Plan, collection);
114
+ expect(instance.meta.project).toBe("acme");
115
+ expect(instance.meta.status).toBe("draft");
116
+ });
117
+
118
+ it("frontmatter overrides pattern-inferred values", () => {
119
+ const doc = collection.createDocument({
120
+ id: "plans/acme/launch",
121
+ content: "# Launch Plan\n",
122
+ meta: { project: "override-corp" },
123
+ });
124
+ const instance = createModelInstance(doc, Plan, collection);
125
+ expect(instance.meta.project).toBe("override-corp");
126
+ });
127
+
128
+ it("defaults < pattern < frontmatter priority chain", () => {
129
+ const ModelWithDefaults = defineModel("ModelWithDefaults", {
130
+ prefix: "items",
131
+ pattern: "items/:category/:slug",
132
+ meta: z.object({
133
+ category: z.string().default("uncategorized"),
134
+ slug: z.string().optional(),
135
+ tag: z.string().default("none"),
136
+ }),
137
+ defaults: { category: "default-cat", tag: "default-tag" },
138
+ });
139
+
140
+ const doc = collection.createDocument({
141
+ id: "items/electronics/phone",
142
+ content: "# Phone\n",
143
+ meta: { tag: "frontmatter-tag" },
144
+ });
145
+ const instance = createModelInstance(doc, ModelWithDefaults, collection);
146
+
147
+ // category: defaults="default-cat", pattern="electronics", frontmatter=absent → "electronics"
148
+ expect(instance.meta.category).toBe("electronics");
149
+ // tag: defaults="default-tag", pattern=absent, frontmatter="frontmatter-tag" → "frontmatter-tag"
150
+ expect(instance.meta.tag).toBe("frontmatter-tag");
151
+ });
152
+
153
+ it("no pattern means no change in behavior", () => {
154
+ const NoPattern = defineModel("NoPattern", {
155
+ prefix: "things",
156
+ meta: z.object({
157
+ status: z.string().default("new"),
158
+ }),
159
+ });
160
+
161
+ const doc = collection.createDocument({
162
+ id: "things/foo",
163
+ content: "# Foo\n",
164
+ meta: {},
165
+ });
166
+ const instance = createModelInstance(doc, NoPattern, collection);
167
+ expect(instance.meta.status).toBe("new");
168
+ });
169
+
170
+ it("validateDocument uses pattern-inferred values", () => {
171
+ const doc = collection.createDocument({
172
+ id: "plans/acme/launch",
173
+ content: "# Launch Plan\n",
174
+ meta: {},
175
+ });
176
+ const result = validateDocument(doc, Plan);
177
+ // project is inferred from pattern, status gets default → should be valid
178
+ expect(result.valid).toBe(true);
179
+ });
180
+
181
+ it("validateDocument fails when pattern doesn't match and required field missing", () => {
182
+ const doc = collection.createDocument({
183
+ id: "other/something",
184
+ content: "# Something\n",
185
+ meta: {},
186
+ });
187
+ const result = validateDocument(doc, Plan);
188
+ // pattern won't match, project is required with no default → should fail
189
+ expect(result.valid).toBe(false);
190
+ });
191
+ });
@@ -0,0 +1,431 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import {
3
+ queryDSLSchema,
4
+ parseWhereClause,
5
+ parseSortClause,
6
+ executeQueryDSL,
7
+ } from "../src/query/query-dsl";
8
+ import { Collection } from "../src/collection";
9
+ import { createTestCollection } from "./helpers";
10
+ import { Epic } from "./fixtures/sdlc/models";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Schema validation
14
+ // ---------------------------------------------------------------------------
15
+
16
+ describe("queryDSLSchema", () => {
17
+ it("validates a minimal query", () => {
18
+ const result = queryDSLSchema.safeParse({ model: "Epic" });
19
+ expect(result.success).toBe(true);
20
+ });
21
+
22
+ it("validates a full query", () => {
23
+ const result = queryDSLSchema.safeParse({
24
+ model: "Epic",
25
+ where: { "meta.status": "created", "meta.priority": { $gt: 3 } },
26
+ sort: { "meta.priority": "desc" },
27
+ select: ["id", "title"],
28
+ limit: 10,
29
+ offset: 5,
30
+ method: "fetchAll",
31
+ });
32
+ expect(result.success).toBe(true);
33
+ });
34
+
35
+ it("rejects missing model", () => {
36
+ const result = queryDSLSchema.safeParse({ where: {} });
37
+ expect(result.success).toBe(false);
38
+ });
39
+
40
+ it("rejects negative limit", () => {
41
+ const result = queryDSLSchema.safeParse({ model: "Epic", limit: -1 });
42
+ expect(result.success).toBe(false);
43
+ });
44
+
45
+ it("rejects negative offset", () => {
46
+ const result = queryDSLSchema.safeParse({ model: "Epic", offset: -1 });
47
+ expect(result.success).toBe(false);
48
+ });
49
+
50
+ it("rejects non-integer limit", () => {
51
+ const result = queryDSLSchema.safeParse({ model: "Epic", limit: 1.5 });
52
+ expect(result.success).toBe(false);
53
+ });
54
+
55
+ it("rejects invalid method", () => {
56
+ const result = queryDSLSchema.safeParse({
57
+ model: "Epic",
58
+ method: "deleteAll",
59
+ });
60
+ expect(result.success).toBe(false);
61
+ });
62
+
63
+ it("defaults method to fetchAll", () => {
64
+ const result = queryDSLSchema.parse({ model: "Epic" });
65
+ expect(result.method).toBe("fetchAll");
66
+ });
67
+ });
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // parseWhereClause
71
+ // ---------------------------------------------------------------------------
72
+
73
+ describe("parseWhereClause", () => {
74
+ it("converts literal string to eq condition", () => {
75
+ const conditions = parseWhereClause({ "meta.status": "active" });
76
+ expect(conditions).toEqual([
77
+ { path: "meta.status", operator: "eq", value: "active" },
78
+ ]);
79
+ });
80
+
81
+ it("converts literal number to eq condition", () => {
82
+ const conditions = parseWhereClause({ "meta.count": 5 });
83
+ expect(conditions).toEqual([
84
+ { path: "meta.count", operator: "eq", value: 5 },
85
+ ]);
86
+ });
87
+
88
+ it("converts literal boolean to eq condition", () => {
89
+ const conditions = parseWhereClause({ "meta.active": true });
90
+ expect(conditions).toEqual([
91
+ { path: "meta.active", operator: "eq", value: true },
92
+ ]);
93
+ });
94
+
95
+ it("converts null to eq condition", () => {
96
+ const conditions = parseWhereClause({ "meta.field": null });
97
+ expect(conditions).toEqual([
98
+ { path: "meta.field", operator: "eq", value: null },
99
+ ]);
100
+ });
101
+
102
+ it("converts array to in condition", () => {
103
+ const conditions = parseWhereClause({
104
+ "meta.status": ["active", "draft"],
105
+ });
106
+ expect(conditions).toEqual([
107
+ { path: "meta.status", operator: "in", value: ["active", "draft"] },
108
+ ]);
109
+ });
110
+
111
+ it("converts operator object to condition", () => {
112
+ const conditions = parseWhereClause({
113
+ "meta.priority": { $gt: 5 },
114
+ });
115
+ expect(conditions).toEqual([
116
+ { path: "meta.priority", operator: "gt", value: 5 },
117
+ ]);
118
+ });
119
+
120
+ it("handles multiple operators on the same path", () => {
121
+ const conditions = parseWhereClause({
122
+ "meta.priority": { $gte: 3, $lte: 8 },
123
+ });
124
+ expect(conditions).toEqual([
125
+ { path: "meta.priority", operator: "gte", value: 3 },
126
+ { path: "meta.priority", operator: "lte", value: 8 },
127
+ ]);
128
+ });
129
+
130
+ it("handles mixed literal and operator values", () => {
131
+ const conditions = parseWhereClause({
132
+ "meta.status": "active",
133
+ "meta.priority": { $gt: 5 },
134
+ title: { $contains: "Auth" },
135
+ });
136
+ expect(conditions.length).toBe(3);
137
+ expect(conditions[0].operator).toBe("eq");
138
+ expect(conditions[1].operator).toBe("gt");
139
+ expect(conditions[2].operator).toBe("contains");
140
+ });
141
+
142
+ it("supports all operators", () => {
143
+ const ops = [
144
+ "$eq",
145
+ "$neq",
146
+ "$in",
147
+ "$notIn",
148
+ "$gt",
149
+ "$lt",
150
+ "$gte",
151
+ "$lte",
152
+ "$contains",
153
+ "$startsWith",
154
+ "$endsWith",
155
+ "$regex",
156
+ "$exists",
157
+ ];
158
+ for (const op of ops) {
159
+ const conditions = parseWhereClause({
160
+ field: { [op]: "value" },
161
+ });
162
+ expect(conditions.length).toBe(1);
163
+ }
164
+ });
165
+
166
+ it("throws on unknown operator", () => {
167
+ expect(() =>
168
+ parseWhereClause({ field: { $unknown: "value" } }),
169
+ ).toThrow("Unknown operator: $unknown");
170
+ });
171
+
172
+ it("throws on regex pattern exceeding max length", () => {
173
+ const longPattern = "a".repeat(201);
174
+ expect(() =>
175
+ parseWhereClause({ field: { $regex: longPattern } }),
176
+ ).toThrow("Regex pattern exceeds maximum length");
177
+ });
178
+
179
+ it("rejects __proto__ path segment", () => {
180
+ expect(() =>
181
+ parseWhereClause({ "__proto__.polluted": "value" }),
182
+ ).toThrow('Forbidden path segment "__proto__"');
183
+ });
184
+
185
+ it("rejects constructor path segment", () => {
186
+ expect(() =>
187
+ parseWhereClause({ "meta.constructor.name": "value" }),
188
+ ).toThrow('Forbidden path segment "constructor"');
189
+ });
190
+
191
+ it("rejects prototype path segment", () => {
192
+ expect(() =>
193
+ parseWhereClause({ "prototype.method": "value" }),
194
+ ).toThrow('Forbidden path segment "prototype"');
195
+ });
196
+ });
197
+
198
+ // ---------------------------------------------------------------------------
199
+ // parseSortClause
200
+ // ---------------------------------------------------------------------------
201
+
202
+ describe("parseSortClause", () => {
203
+ it("parses object form", () => {
204
+ const sorts = parseSortClause({ "meta.priority": "desc", title: "asc" });
205
+ expect(sorts).toEqual([
206
+ { path: "meta.priority", direction: "desc" },
207
+ { path: "title", direction: "asc" },
208
+ ]);
209
+ });
210
+
211
+ it("passes through array form", () => {
212
+ const input = [{ path: "title", direction: "asc" as const }];
213
+ const sorts = parseSortClause(input);
214
+ expect(sorts).toEqual(input);
215
+ });
216
+
217
+ it("returns empty array for undefined", () => {
218
+ expect(parseSortClause(undefined)).toEqual([]);
219
+ });
220
+
221
+ it("returns empty array for null", () => {
222
+ expect(parseSortClause(null)).toEqual([]);
223
+ });
224
+ });
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // executeQueryDSL — integration tests
228
+ // ---------------------------------------------------------------------------
229
+
230
+ describe("executeQueryDSL", () => {
231
+ let collection: Collection;
232
+
233
+ beforeEach(async () => {
234
+ collection = await createTestCollection();
235
+ });
236
+
237
+ it("returns all instances of a model", async () => {
238
+ const results = await executeQueryDSL(collection, {
239
+ model: "Epic",
240
+ method: "fetchAll",
241
+ });
242
+ expect(Array.isArray(results)).toBe(true);
243
+ expect((results as any[]).length).toBe(2);
244
+ });
245
+
246
+ it("filters with implicit $eq", async () => {
247
+ const results = await executeQueryDSL(collection, {
248
+ model: "Epic",
249
+ where: { "meta.priority": "high" },
250
+ method: "fetchAll",
251
+ });
252
+ expect((results as any[]).length).toBe(1);
253
+ expect((results as any[])[0].meta.priority).toBe("high");
254
+ });
255
+
256
+ it("filters with $exists", async () => {
257
+ const results = await executeQueryDSL(collection, {
258
+ model: "Epic",
259
+ where: { "meta.priority": { $exists: true } },
260
+ method: "fetchAll",
261
+ });
262
+ expect((results as any[]).length).toBe(1);
263
+ });
264
+
265
+ it("filters with $contains on title", async () => {
266
+ const results = await executeQueryDSL(collection, {
267
+ model: "Epic",
268
+ where: { title: { $contains: "Authentication" } },
269
+ method: "fetchAll",
270
+ });
271
+ expect((results as any[]).length).toBe(1);
272
+ });
273
+
274
+ it("sorts results", async () => {
275
+ const results = await executeQueryDSL(collection, {
276
+ model: "Epic",
277
+ sort: { title: "desc" },
278
+ method: "fetchAll",
279
+ }) as any[];
280
+ expect(results[0].title.localeCompare(results[1].title)).toBeGreaterThan(0);
281
+ });
282
+
283
+ it("applies limit", async () => {
284
+ const results = await executeQueryDSL(collection, {
285
+ model: "Epic",
286
+ limit: 1,
287
+ method: "fetchAll",
288
+ });
289
+ expect((results as any[]).length).toBe(1);
290
+ });
291
+
292
+ it("applies offset", async () => {
293
+ const all = await executeQueryDSL(collection, {
294
+ model: "Epic",
295
+ sort: { title: "asc" },
296
+ method: "fetchAll",
297
+ }) as any[];
298
+
299
+ const offsetResults = await executeQueryDSL(collection, {
300
+ model: "Epic",
301
+ sort: { title: "asc" },
302
+ offset: 1,
303
+ method: "fetchAll",
304
+ }) as any[];
305
+
306
+ expect(offsetResults.length).toBe(1);
307
+ expect(offsetResults[0].title).toBe(all[1].title);
308
+ });
309
+
310
+ it("applies limit + offset for pagination", async () => {
311
+ const all = await executeQueryDSL(collection, {
312
+ model: "Epic",
313
+ sort: { title: "asc" },
314
+ method: "fetchAll",
315
+ }) as any[];
316
+
317
+ const page = await executeQueryDSL(collection, {
318
+ model: "Epic",
319
+ sort: { title: "asc" },
320
+ limit: 1,
321
+ offset: 1,
322
+ method: "fetchAll",
323
+ }) as any[];
324
+
325
+ expect(page.length).toBe(1);
326
+ expect(page[0].title).toBe(all[1].title);
327
+ });
328
+
329
+ it("method count returns count object", async () => {
330
+ const result = await executeQueryDSL(collection, {
331
+ model: "Epic",
332
+ method: "count",
333
+ });
334
+ expect(result).toEqual({ count: 2 });
335
+ });
336
+
337
+ it("method first returns single result", async () => {
338
+ const result = await executeQueryDSL(collection, {
339
+ model: "Epic",
340
+ method: "first",
341
+ });
342
+ expect(result).toBeDefined();
343
+ expect((result as any).title).toBeDefined();
344
+ });
345
+
346
+ it("method last returns single result", async () => {
347
+ const result = await executeQueryDSL(collection, {
348
+ model: "Epic",
349
+ method: "last",
350
+ });
351
+ expect(result).toBeDefined();
352
+ expect((result as any).title).toBeDefined();
353
+ });
354
+
355
+ it("select filters output fields", async () => {
356
+ const results = await executeQueryDSL(collection, {
357
+ model: "Epic",
358
+ select: ["id", "title"],
359
+ method: "fetchAll",
360
+ }) as any[];
361
+
362
+ for (const r of results) {
363
+ expect(Object.keys(r)).toEqual(["id", "title"]);
364
+ }
365
+ });
366
+
367
+ it("throws on unknown model", async () => {
368
+ await expect(
369
+ executeQueryDSL(collection, {
370
+ model: "NonExistent",
371
+ method: "fetchAll",
372
+ }),
373
+ ).rejects.toThrow("Unknown model: NonExistent");
374
+ });
375
+
376
+ it("resolves model by prefix", async () => {
377
+ const results = await executeQueryDSL(collection, {
378
+ model: "epics",
379
+ method: "fetchAll",
380
+ });
381
+ expect((results as any[]).length).toBe(2);
382
+ });
383
+ });
384
+
385
+ // ---------------------------------------------------------------------------
386
+ // CollectionQuery limit/offset
387
+ // ---------------------------------------------------------------------------
388
+
389
+ describe("CollectionQuery limit/offset", () => {
390
+ let collection: Collection;
391
+
392
+ beforeEach(async () => {
393
+ collection = await createTestCollection();
394
+ });
395
+
396
+ it("limit restricts results", async () => {
397
+ const results = await collection
398
+ .query(Epic)
399
+ .limit(1)
400
+ .fetchAll();
401
+ expect(results.length).toBe(1);
402
+ });
403
+
404
+ it("offset skips results", async () => {
405
+ const all = await collection.query(Epic).sort("title").fetchAll();
406
+ const offset = await collection
407
+ .query(Epic)
408
+ .sort("title")
409
+ .offset(1)
410
+ .fetchAll();
411
+ expect(offset.length).toBe(1);
412
+ expect(offset[0].title).toBe(all[1].title);
413
+ });
414
+
415
+ it("limit(0) returns empty array", async () => {
416
+ const results = await collection.query(Epic).limit(0).fetchAll();
417
+ expect(results.length).toBe(0);
418
+ });
419
+
420
+ it("limit + offset chain together", async () => {
421
+ const all = await collection.query(Epic).sort("title").fetchAll();
422
+ const page = await collection
423
+ .query(Epic)
424
+ .sort("title")
425
+ .offset(1)
426
+ .limit(1)
427
+ .fetchAll();
428
+ expect(page.length).toBe(1);
429
+ expect(page[0].title).toBe(all[1].title);
430
+ });
431
+ });
@@ -164,4 +164,33 @@ describe("CollectionQuery", () => {
164
164
  const all = await collection.query(Epic).fetchAll();
165
165
  expect(all.length).toBe(2);
166
166
  });
167
+
168
+ it("sort orders results ascending by default", async () => {
169
+ const epics = await collection.query(Epic).sort("title").fetchAll();
170
+ expect(epics.length).toBe(2);
171
+ expect(epics[0].title.localeCompare(epics[1].title)).toBeLessThan(0);
172
+ });
173
+
174
+ it("sort orders results descending", async () => {
175
+ const epics = await collection.query(Epic).sort("title", "desc").fetchAll();
176
+ expect(epics.length).toBe(2);
177
+ expect(epics[0].title.localeCompare(epics[1].title)).toBeGreaterThan(0);
178
+ });
179
+
180
+ it("sort handles null values (pushed to end in asc)", async () => {
181
+ // authentication has priority "high", searching-and-browsing has no priority
182
+ const epics = await collection.query(Epic).sort("meta.priority").fetchAll();
183
+ expect(epics[0].meta.priority).toBe("high");
184
+ expect(epics[1].meta.priority).toBeUndefined();
185
+ });
186
+
187
+ it("sort chains with where", async () => {
188
+ const epics = await collection
189
+ .query(Epic)
190
+ .where("meta.status", "created")
191
+ .sort("title", "desc")
192
+ .fetchAll();
193
+ expect(epics.length).toBe(2);
194
+ expect(epics[0].title.localeCompare(epics[1].title)).toBeGreaterThan(0);
195
+ });
167
196
  });