@davidtkramer/convex-relations 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 David Kramer
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,539 @@
1
+ # `@davidtkramer/convex-relations`
2
+
3
+ Typed relations and query composition for Convex backends.
4
+
5
+ `convex-relations` is a server-side query facade for Convex. It gives you typed
6
+ table namespaces from your generated `DataModel`, strongly typed index lookups,
7
+ relation expansion with `with(...)`, join-table traversal with `via(...)`,
8
+ batch loading with `.in(...)`, and arbitrary computed fields with `compute(...)`.
9
+
10
+ It is designed for Convex query and mutation code, not frontend query clients.
11
+
12
+ ## Table of Contents
13
+
14
+ - [Installation](#installation)
15
+ - [Example](#example)
16
+ - [Equivalent Convex Code](#equivalent-convex-code)
17
+ - [Quick Start](#quick-start)
18
+ - [Core Concepts](#core-concepts)
19
+ - [API](#api)
20
+ - [Table Access Patterns](#table-access-patterns)
21
+ - [Relation Expansion with `with(...)`](#relation-expansion-with-with)
22
+ - [Join Table Traversal with `via(...)`](#join-table-traversal-with-via)
23
+ - [Terminals](#terminals)
24
+ - [Error Semantics](#error-semantics)
25
+ - [Performance Characteristics](#performance-characteristics)
26
+ - [Comparison to `convex-helpers/server/relationships`](#comparison-to-convex-helpersserverrelationships)
27
+ - [Type Notes](#type-notes)
28
+ - [License](#license)
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ npm install @davidtkramer/convex-relations
34
+ ```
35
+
36
+ ```bash
37
+ pnpm add @davidtkramer/convex-relations
38
+ ```
39
+
40
+ ```bash
41
+ bun add @davidtkramer/convex-relations
42
+ ```
43
+
44
+ ```bash
45
+ yarn add @davidtkramer/convex-relations
46
+ ```
47
+
48
+ ## Example
49
+
50
+ With `convex-relations`, a nested API-ready query can look like this:
51
+
52
+ ```ts
53
+ import { query } from "./_generated/server";
54
+
55
+ export const getPost = query({
56
+ args: {},
57
+ handler: async (ctx) => {
58
+ const post = await ctx.q.posts
59
+ .bySlug("hello-world")
60
+ .with((post) => ({
61
+ author: ctx.q.authors.find(post.authorId),
62
+ recentComments: ctx.q.comments
63
+ .byPostId(post._id)
64
+ .order("desc")
65
+ .with((comment) => ({
66
+ author: ctx.q.authors.find(comment.authorId),
67
+ }))
68
+ .take(10),
69
+ categories: ctx.q.categories
70
+ .via("postCategories", "categoryId")
71
+ .byPostId(post._id)
72
+ .many(),
73
+ }))
74
+ .unique();
75
+
76
+ // post.author is an author document
77
+ console.log(post.author.name);
78
+
79
+ // post.recentComments is a list of comments with nested authors
80
+ console.log(post.recentComments[0]?.author.name);
81
+
82
+ // post.categories is already shaped as related category documents
83
+ console.log(post.categories.map((category) => category.slug));
84
+
85
+ return post;
86
+ },
87
+ });
88
+ ```
89
+
90
+ This example shows most of the value proposition in one place:
91
+
92
+ - table-scoped access through `q.posts`, `q.comments`, `q.categories`
93
+ - typed index lookup with `.bySlug(...)` and `.byPostId(...)`
94
+ - nested relation expansion with `.with(...)` inside `.with(...)`
95
+ - typed join traversal with `.via(...)`
96
+ - parallel nested loading inside one `with(...)`
97
+ - a final strongly typed result from one expression
98
+
99
+ ## Equivalent Convex Code
100
+
101
+ Without `convex-relations`, you end up assembling the same result shape by hand:
102
+
103
+ ```ts
104
+ const post = await ctx.db
105
+ .query("posts")
106
+ .withIndex("bySlug", (q) => q.eq("slug", args.slug))
107
+ .unique();
108
+
109
+ if (!post) {
110
+ throw new Error("Post not found");
111
+ }
112
+
113
+ const [author, recentComments, postCategoryLinks] = await Promise.all([
114
+ ctx.db.get(post.authorId),
115
+ ctx.db
116
+ .query("comments")
117
+ .withIndex("byPostId", (q) => q.eq("postId", post._id))
118
+ .order("desc")
119
+ .take(10),
120
+ ctx.db
121
+ .query("postCategories")
122
+ .withIndex("byPostId", (q) => q.eq("postId", post._id))
123
+ .collect(),
124
+ ]);
125
+
126
+ const recentCommentsWithAuthors = await Promise.all(
127
+ recentComments.map(async (comment) => ({
128
+ ...comment,
129
+ author: await ctx.db.get(comment.authorId),
130
+ })),
131
+ );
132
+
133
+ const categories = (
134
+ await Promise.all(
135
+ postCategoryLinks.map((link) => ctx.db.get(link.categoryId)),
136
+ )
137
+ ).filter((category) => category !== null);
138
+
139
+ return {
140
+ ...post,
141
+ author,
142
+ recentComments: recentCommentsWithAuthors,
143
+ categories,
144
+ };
145
+ ```
146
+
147
+ That works, but you are responsible for:
148
+
149
+ - deciding what should run in parallel
150
+ - remembering to manually `Promise.all(...)` nested relationships
151
+ - traversing join tables by hand
152
+ - assembling the final tree shape yourself for API responses
153
+ - keeping the whole thing type-safe as it grows
154
+
155
+ ## Quick Start
156
+
157
+ Most apps expose the facade on `ctx.q` through `convex-helpers` custom function
158
+ wrappers. A minimal setup looks like this:
159
+
160
+ ```ts
161
+ // convex/lib/functions.ts
162
+ import { customCtx, customQuery } from "convex-helpers/server/customFunctions";
163
+ import { query as baseQuery } from "./_generated/server";
164
+ import type { DataModel } from "./_generated/dataModel";
165
+ import { createQueryFacade } from "@davidtkramer/convex-relations";
166
+
167
+ export const query = customQuery(
168
+ baseQuery,
169
+ customCtx((ctx: { db: any }) => ({
170
+ q: createQueryFacade<DataModel>(ctx.db),
171
+ })),
172
+ );
173
+ ```
174
+
175
+ Once you do that, usage looks like this:
176
+
177
+ ```ts
178
+ const post = await ctx.q.posts
179
+ .bySlug("hello-world")
180
+ .with((post) => ({
181
+ author: ctx.q.authors.find(post.authorId),
182
+ comments: ctx.q.comments.byPostId(post._id).order("desc").take(10),
183
+ }))
184
+ .unique();
185
+ ```
186
+
187
+ ## Core Concepts
188
+
189
+ ### Table namespaces
190
+
191
+ Every table becomes a namespace on the returned facade:
192
+
193
+ ```ts
194
+ await ctx.q.posts.many();
195
+ await ctx.q.authors.bySlug("ada-lovelace").unique();
196
+ await ctx.q.comments.byPostId(postId).order("desc").take(20);
197
+ ```
198
+
199
+ ### Indexes become methods
200
+
201
+ For example, imagine these index definitions:
202
+
203
+ ```ts
204
+ authors: defineTable({
205
+ slug: v.string(),
206
+ name: v.string(),
207
+ }).index("bySlug", ["slug"]);
208
+
209
+ comments: defineTable({
210
+ postId: v.id("posts"),
211
+ authorId: v.id("authors"),
212
+ status: v.union(v.literal("pending"), v.literal("approved")),
213
+ body: v.string(),
214
+ })
215
+ .index("byPostId", ["postId"])
216
+ .index("byPostIdAndStatus", ["postId", "status"]);
217
+ ```
218
+
219
+ ```ts
220
+ const author = await ctx.q.authors.bySlug("ada-lovelace").unique();
221
+ const comments = await ctx.q.comments.byPostId(postId).many();
222
+ const approvedComments = await ctx.q.comments
223
+ .byPostIdAndStatus({ postId, status: "approved" })
224
+ .many();
225
+ ```
226
+
227
+ Single-field indexes accept a scalar. Compound indexes accept an object that
228
+ matches a leading slice of the index definition. Zero-argument calls give you
229
+ the indexed range so you can filter, sort, paginate, or take a subset.
230
+
231
+ ## API
232
+
233
+ ### `createQueryFacade<DataModel>(db)`
234
+
235
+ Creates a typed facade over your Convex `db`.
236
+
237
+ ```ts
238
+ import { createQueryFacade } from "@davidtkramer/convex-relations";
239
+ import type { DataModel } from "./_generated/dataModel";
240
+
241
+ const q = createQueryFacade<DataModel>(ctx.db);
242
+ ```
243
+
244
+ ### `compute(load)`
245
+
246
+ Wraps arbitrary async or sync work so it can be used inside `with(...)`.
247
+
248
+ ```ts
249
+ const post = await q.posts
250
+ .bySlug("hello-world")
251
+ .with((post) => ({
252
+ readingTimeMinutes: compute(() =>
253
+ Math.ceil(post.body.split(/\s+/).length / 200),
254
+ ),
255
+ }))
256
+ .unique();
257
+ ```
258
+
259
+ ## Table Access Patterns
260
+
261
+ ### `find(id)` and `findOrNull(id)`
262
+
263
+ Direct `_id` lookup.
264
+
265
+ ```ts
266
+ const post = await q.posts.find(postId);
267
+ const maybePost = await q.posts.findOrNull(postId);
268
+ ```
269
+
270
+ `find(...)` throws if the document is missing. `findOrNull(...)` returns `null`.
271
+
272
+ ### Full table or index range queries
273
+
274
+ Zero-argument table or index access creates a range query.
275
+
276
+ ```ts
277
+ const latestPosts = await q.posts.order("desc").take(20);
278
+
279
+ const authorPosts = await q.posts
280
+ .byAuthorId()
281
+ .filter((query) => query.eq(query.field("authorId"), authorId))
282
+ .order("desc")
283
+ .many();
284
+ ```
285
+
286
+ ### Indexed lookup by value
287
+
288
+ Single-field indexes accept a scalar:
289
+
290
+ ```ts
291
+ const author = await q.authors.bySlug("ada-lovelace").unique();
292
+ ```
293
+
294
+ Compound indexes accept an object containing a valid prefix:
295
+
296
+ ```ts
297
+ const comments = await q.comments
298
+ .byPostIdAndCreatedAt({ postId })
299
+ .order("desc")
300
+ .take(20);
301
+
302
+ const exactOrPrefix = await q.comments
303
+ .byPostIdAndCreatedAt({ postId, createdAt: 1700000000000 })
304
+ .many();
305
+ ```
306
+
307
+ ### Indexed lookup by selector function
308
+
309
+ You can also pass Convex's index selector callback:
310
+
311
+ ```ts
312
+ const recentComments = await q.comments
313
+ .byPostIdAndCreatedAt((q) => q.eq("postId", postId).gt("createdAt", cutoff))
314
+ .many();
315
+ ```
316
+
317
+ ### Batch lookup with `.in(...)`
318
+
319
+ Available on `_id` and indexed entrypoints.
320
+
321
+ ```ts
322
+ const posts = await q.posts.in(postIds).many();
323
+
324
+ const categories = await q.categories.bySlug
325
+ .in(["typescript", "convex"])
326
+ .many();
327
+ ```
328
+
329
+ Batch lookups skip missing rows.
330
+
331
+ ## Relation Expansion with `with(...)`
332
+
333
+ `with(...)` lets you attach related data or computed fields before a terminal.
334
+
335
+ ```ts
336
+ const post = await q.posts
337
+ .bySlug("hello-world")
338
+ .with((post) => ({
339
+ author: q.authors.find(post.authorId),
340
+ comments: q.comments.byPostId(post._id).order("desc").take(10),
341
+ commentCount: compute(async () => {
342
+ const comments = await q.comments.byPostId(post._id).many();
343
+ return comments.length;
344
+ }),
345
+ }))
346
+ .unique();
347
+ ```
348
+
349
+ You can chain `with(...)` calls:
350
+
351
+ ```ts
352
+ const post = await q.posts
353
+ .bySlug("hello-world")
354
+ .with((post) => ({
355
+ author: q.authors.find(post.authorId),
356
+ }))
357
+ .with((post) => ({
358
+ otherPostsByAuthor: q.posts.byAuthorId(post.author._id).many(),
359
+ }))
360
+ .unique();
361
+ ```
362
+
363
+ Each `with(...)` stage sees fields added by earlier stages.
364
+
365
+ ## Join Table Traversal with `via(...)`
366
+
367
+ Use `via(joinTable, targetField)` for many-to-many relationships.
368
+
369
+ Given `postCategories { postId, categoryId }`, you can fetch categories for a post:
370
+
371
+ ```ts
372
+ const categories = await q.categories
373
+ .via("postCategories", "categoryId")
374
+ .byPostId(postId)
375
+ .many();
376
+ ```
377
+
378
+ You can also attach the join row with `withSource(...)`:
379
+
380
+ ```ts
381
+ const categories = await q.categories
382
+ .via("postCategories", "categoryId")
383
+ .byPostId(postId)
384
+ .withSource("link")
385
+ .many();
386
+
387
+ categories[0]?.link.postId;
388
+ categories[0]?.link.categoryId;
389
+ ```
390
+
391
+ This is useful when the join table stores metadata like ordering, role, or timestamps.
392
+
393
+ ## Terminals
394
+
395
+ ### `unique()` / `uniqueOrNull()`
396
+
397
+ Use when the query should match at most one document.
398
+
399
+ ```ts
400
+ const author = await q.authors.bySlug("ada-lovelace").unique();
401
+ const maybeAuthor = await q.authors.bySlug("missing").uniqueOrNull();
402
+ ```
403
+
404
+ ### `first()` / `firstOrNull()`
405
+
406
+ Use when you want the first result from an ordered or filtered range query.
407
+
408
+ ```ts
409
+ const latestComment = await q.comments.byPostId(postId).order("desc").first();
410
+ const maybeLatestComment = await q.comments
411
+ .byPostId(postId)
412
+ .order("desc")
413
+ .firstOrNull();
414
+ ```
415
+
416
+ ### `many()`
417
+
418
+ Collects all matching rows.
419
+
420
+ ```ts
421
+ const comments = await q.comments.byPostId(postId).many();
422
+ ```
423
+
424
+ ### `take(count)`
425
+
426
+ Collects up to `count` rows.
427
+
428
+ ```ts
429
+ const comments = await q.comments.byPostId(postId).order("desc").take(20);
430
+ ```
431
+
432
+ ### `paginate(opts)`
433
+
434
+ Returns Convex-style pagination output.
435
+
436
+ ```ts
437
+ const page = await q.posts.byAuthorId(authorId).paginate({
438
+ cursor: null,
439
+ numItems: 25,
440
+ });
441
+ ```
442
+
443
+ ## Error Semantics
444
+
445
+ - `find(...)` throws if the document is missing
446
+ - `unique()` throws if there is no match
447
+ - `unique()` also throws if there are multiple matches
448
+ - `first()` throws if there is no match
449
+ - `findOrNull()`, `uniqueOrNull()`, and `firstOrNull()` return `null` instead
450
+ - `via(...).unique()` normalizes its duplicate error to include the target table and join index
451
+
452
+ ## Performance Characteristics
453
+
454
+ ### What runs in parallel
455
+
456
+ Within a single `with(...)` stage, every field in the returned object runs in parallel.
457
+
458
+ ```ts
459
+ const post = await q.posts.find(postId).with((post) => ({
460
+ author: q.authors.find(post.authorId),
461
+ comments: q.comments.byPostId(post._id).take(10),
462
+ categoryCount: compute(async () => {
463
+ const categories = await q.categories
464
+ .via("postCategories", "categoryId")
465
+ .byPostId(post._id)
466
+ .many();
467
+ return categories.length;
468
+ }),
469
+ }));
470
+ ```
471
+
472
+ Those three branches are executed concurrently.
473
+
474
+ For collection queries, expansion also runs in parallel across items:
475
+
476
+ - the query fetches the base rows
477
+ - each row is expanded concurrently
478
+ - each field inside a single expansion stage is also concurrent
479
+
480
+ ### What runs sequentially
481
+
482
+ Chained `with(...)` stages are sequential by design.
483
+
484
+ ```ts
485
+ q.posts
486
+ .with((post) => ({ author: q.authors.find(post.authorId) }))
487
+ .with((post) => ({ otherPosts: q.posts.byAuthorId(post.author._id).many() }));
488
+ ```
489
+
490
+ The second stage waits for the first stage, because it depends on fields added earlier.
491
+
492
+ `via(...)` currently resolves target documents by fetching join rows first, then loading each target document individually. This is correct and predictable, but it is not a single batched join at the database level.
493
+
494
+ ### Practical guidance
495
+
496
+ - Prefer one `with(...)` stage when fields are independent
497
+ - Split into multiple `with(...)` stages only when later fields depend on earlier expansions
498
+ - Use `take(...)` or `paginate(...)` instead of `many()` on large collections
499
+ - Use indexed entrypoints whenever possible
500
+ - Use `.in(...)` when you already have a set of ids or indexed values
501
+
502
+ ## Comparison to `convex-helpers/server/relationships`
503
+
504
+ If you started from Convex relationship helpers like `getOneFrom`, `getManyFrom`, or `getManyVia`, this library aims to provide the same kind of relational navigation with better composition.
505
+
506
+ This:
507
+
508
+ ```ts
509
+ const categories = await q.categories
510
+ .via("postCategories", "categoryId")
511
+ .byPostId(postId)
512
+ .many();
513
+ ```
514
+
515
+ replaces patterns like:
516
+
517
+ ```ts
518
+ const categories = await getManyVia(
519
+ db,
520
+ "postCategories",
521
+ "categoryId",
522
+ "postId",
523
+ postId,
524
+ );
525
+ ```
526
+
527
+ but also composes naturally with `with(...)`, `take(...)`, `firstOrNull()`, and typed nested traversal.
528
+
529
+ ## Type Notes
530
+
531
+ - The facade is generic over your generated `DataModel`
532
+ - Table names, `_id` types, index names, and compound index prefixes are inferred
533
+ - Invalid table names and invalid index names are rejected at compile time
534
+ - Scalar shorthand is only allowed for single-field indexes
535
+ - Compound indexes require a valid prefix object
536
+
537
+ ## License
538
+
539
+ MIT
@@ -0,0 +1,137 @@
1
+ import { GenericDataModel, TableNamesInDataModel, DocumentByName, IndexNames, NamedTableInfo, NamedIndex, GenericDatabaseReader } from 'convex/server';
2
+ import { GenericId } from 'convex/values';
3
+
4
+ type Simplify<T> = {
5
+ [K in keyof T]: T[K];
6
+ } & {};
7
+ type AppTable<DataModel extends GenericDataModel> = TableNamesInDataModel<DataModel>;
8
+ type AppDoc<DataModel extends GenericDataModel, Table extends AppTable<DataModel>> = DocumentByName<DataModel, Table>;
9
+ type UserIndex<DataModel extends GenericDataModel, Table extends AppTable<DataModel>> = Exclude<IndexNames<NamedTableInfo<DataModel, Table>>, 'by_creation_time'> & string;
10
+ type RawIndexFields<DataModel extends GenericDataModel, Table extends AppTable<DataModel>, IndexName extends UserIndex<DataModel, Table>> = NamedIndex<NamedTableInfo<DataModel, Table>, IndexName>;
11
+ type IndexFields<DataModel extends GenericDataModel, Table extends AppTable<DataModel>, IndexName extends UserIndex<DataModel, Table>> = Exclude<RawIndexFields<DataModel, Table, IndexName>[number], '_creationTime'>;
12
+ type SingleIndexField<DataModel extends GenericDataModel, Table extends AppTable<DataModel>, IndexName extends UserIndex<DataModel, Table>> = RawIndexFields<DataModel, Table, IndexName> extends [
13
+ infer Field extends string,
14
+ '_creationTime'
15
+ ] ? Field : never;
16
+ type TuplePrefixValues<DataModel extends GenericDataModel, Table extends AppTable<DataModel>, Fields extends readonly string[], Seen extends readonly string[] = []> = Fields extends readonly [
17
+ infer Head extends string,
18
+ ...infer Tail extends readonly string[]
19
+ ] ? Simplify<{
20
+ [FieldName in [...Seen, Head][number]]: AppDoc<DataModel, Table>[FieldName];
21
+ }> | TuplePrefixValues<DataModel, Table, Tail, [...Seen, Head]> : never;
22
+ type PrefixIndexValues<DataModel extends GenericDataModel, Table extends AppTable<DataModel>, IndexName extends UserIndex<DataModel, Table>> = TuplePrefixValues<DataModel, Table, RawIndexFields<DataModel, Table, IndexName>>;
23
+ type RootIndexValueArg<DataModel extends GenericDataModel, Table extends AppTable<DataModel>, IndexName extends UserIndex<DataModel, Table>> = PrefixIndexValues<DataModel, Table, IndexName> | (SingleIndexField<DataModel, Table, IndexName> extends never ? never : AppDoc<DataModel, Table>[SingleIndexField<DataModel, Table, IndexName>]);
24
+ type StrictRootIndexValueArg<DataModel extends GenericDataModel, Table extends AppTable<DataModel>, IndexName extends UserIndex<DataModel, Table>, Value extends RootIndexValueArg<DataModel, Table, IndexName>> = Value extends PrefixIndexValues<DataModel, Table, IndexName> ? Value & Record<Exclude<keyof Value, IndexFields<DataModel, Table, IndexName>>, never> : Value;
25
+ type TableIndexName<DataModel extends GenericDataModel, Table extends AppTable<DataModel>> = UserIndex<DataModel, Table> | 'by_id';
26
+ type TableIndexValueArg<DataModel extends GenericDataModel, Table extends AppTable<DataModel>, IndexName extends TableIndexName<DataModel, Table>> = IndexName extends 'by_id' ? GenericId<Table> : IndexName extends UserIndex<DataModel, Table> ? RootIndexValueArg<DataModel, Table, IndexName> : never;
27
+ type StrictTableIndexValueArg<DataModel extends GenericDataModel, Table extends AppTable<DataModel>, IndexName extends TableIndexName<DataModel, Table>, Value extends TableIndexValueArg<DataModel, Table, IndexName>> = IndexName extends 'by_id' ? Value : IndexName extends UserIndex<DataModel, Table> ? StrictRootIndexValueArg<DataModel, Table, IndexName, Extract<Value, RootIndexValueArg<DataModel, Table, IndexName>>> : never;
28
+ type IdTargetTable<DataModel extends GenericDataModel, Value> = Value extends GenericId<infer Table extends AppTable<DataModel>> ? Table : never;
29
+ type JoinTargetTable<DataModel extends GenericDataModel, JoinTable extends AppTable<DataModel>, TargetField extends Extract<keyof AppDoc<DataModel, JoinTable>, string>> = IdTargetTable<DataModel, AppDoc<DataModel, JoinTable>[TargetField]>;
30
+ type QueryNode<Output> = PromiseLike<Output> & {
31
+ readonly _executeRoot: () => Promise<Output>;
32
+ };
33
+ type WithSpec = Record<string, QueryNode<any>>;
34
+ type WithBuilder<ParentItem, Spec extends WithSpec | undefined = WithSpec | undefined> = (parent: ParentItem) => Spec;
35
+ type AnyWithBuilder<ParentItem> = WithBuilder<ParentItem, WithSpec | undefined>;
36
+ type BuiltWithSpec<Builder> = Builder extends (...args: any[]) => infer Spec ? Spec : never;
37
+ type ExpandWith<ParentItem, Builder> = Simplify<ParentItem & (BuiltWithSpec<Builder> extends Record<string, unknown> ? {
38
+ [K in keyof BuiltWithSpec<Builder>]: BuiltWithSpec<Builder>[K] extends QueryNode<infer Output> ? Output : never;
39
+ } : {})>;
40
+ type AttachSource<ParentItem, SourceItem, SourceKey extends string> = Simplify<ParentItem & {
41
+ [K in SourceKey]: SourceItem;
42
+ }>;
43
+ type PaginationOptions = {
44
+ numItems: number;
45
+ cursor: string | null;
46
+ };
47
+ type PaginatedResult<Item> = {
48
+ page: Item[];
49
+ isDone: boolean;
50
+ continueCursor: string;
51
+ };
52
+ type QueryFilter = (q: any) => any;
53
+ type IndexSelector = (q: any) => any;
54
+ type ExpandableSingleQueryBuilder<Item, Nullable extends boolean> = QueryNode<Nullable extends true ? Item | null : Item> & {
55
+ with<Builder extends AnyWithBuilder<Item>>(withBuilder: Builder): ExpandableSingleQueryBuilder<ExpandWith<Item, Builder>, Nullable>;
56
+ };
57
+ type SingleQueryBuilder<Item, Nullable extends boolean> = QueryNode<Nullable extends true ? Item | null : Item>;
58
+ type UniqueQueryBuilder<Item> = SingleQueryBuilder<Item, false>;
59
+ type UniqueOrNullQueryBuilder<Item> = SingleQueryBuilder<Item, true>;
60
+ type FirstQueryBuilder<Item> = SingleQueryBuilder<Item, false>;
61
+ type FirstOrNullQueryBuilder<Item> = SingleQueryBuilder<Item, true>;
62
+ type FindQueryBuilder<Item> = ExpandableSingleQueryBuilder<Item, false>;
63
+ type FindOrNullQueryBuilder<Item> = ExpandableSingleQueryBuilder<Item, true>;
64
+ type ManyQueryBuilder<Item> = QueryNode<Item[]>;
65
+ type BatchQueryBuilder<Item> = QueryNode<Item[]>;
66
+ type ManyViaQueryBuilder<Item> = QueryNode<Item[]>;
67
+ type TableQueryFacade<DataModel extends GenericDataModel, Table extends AppTable<DataModel>, Item = AppDoc<DataModel, Table>> = {
68
+ with<Builder extends AnyWithBuilder<Item>>(withBuilder: Builder): TableQueryFacade<DataModel, Table, ExpandWith<Item, Builder>>;
69
+ order(direction: 'asc' | 'desc'): TableQueryFacade<DataModel, Table, Item>;
70
+ filter(filterer: QueryFilter): TableQueryFacade<DataModel, Table, Item>;
71
+ unique(): UniqueQueryBuilder<Item>;
72
+ uniqueOrNull(): UniqueOrNullQueryBuilder<Item>;
73
+ first(): FirstQueryBuilder<Item>;
74
+ firstOrNull(): FirstOrNullQueryBuilder<Item>;
75
+ take(count: number): Promise<Item[]>;
76
+ paginate(opts: PaginationOptions): Promise<PaginatedResult<Item>>;
77
+ many(): ManyQueryBuilder<Item>;
78
+ };
79
+ type TableRangeQueryFacade<DataModel extends GenericDataModel, Table extends AppTable<DataModel>, Item = AppDoc<DataModel, Table>> = {
80
+ with<Builder extends AnyWithBuilder<Item>>(withBuilder: Builder): TableRangeQueryFacade<DataModel, Table, ExpandWith<Item, Builder>>;
81
+ order(direction: 'asc' | 'desc'): TableRangeQueryFacade<DataModel, Table, Item>;
82
+ filter(filterer: QueryFilter): TableRangeQueryFacade<DataModel, Table, Item>;
83
+ unique(): UniqueQueryBuilder<Item>;
84
+ uniqueOrNull(): UniqueOrNullQueryBuilder<Item>;
85
+ first(): FirstQueryBuilder<Item>;
86
+ firstOrNull(): FirstOrNullQueryBuilder<Item>;
87
+ take(count: number): Promise<Item[]>;
88
+ paginate(opts: PaginationOptions): Promise<PaginatedResult<Item>>;
89
+ many(): ManyQueryBuilder<Item>;
90
+ };
91
+ type TableBatchQueryFacade<DataModel extends GenericDataModel, Table extends AppTable<DataModel>, Item = AppDoc<DataModel, Table>> = {
92
+ with<Builder extends AnyWithBuilder<Item>>(withBuilder: Builder): TableBatchQueryFacade<DataModel, Table, ExpandWith<Item, Builder>>;
93
+ many(): BatchQueryBuilder<Item>;
94
+ };
95
+ type ViaQueryFacade<DataModel extends GenericDataModel, TargetTable extends AppTable<DataModel>, JoinTable extends AppTable<DataModel>, Item = AppDoc<DataModel, TargetTable>> = {
96
+ with<Builder extends AnyWithBuilder<Item>>(withBuilder: Builder): ViaQueryFacade<DataModel, TargetTable, JoinTable, ExpandWith<Item, Builder>>;
97
+ order(direction: 'asc' | 'desc'): ViaQueryFacade<DataModel, TargetTable, JoinTable, Item>;
98
+ filter(filterer: QueryFilter): ViaQueryFacade<DataModel, TargetTable, JoinTable, Item>;
99
+ withSource<const SourceKey extends string>(key: SourceKey): ViaQueryFacade<DataModel, TargetTable, JoinTable, AttachSource<Item, AppDoc<DataModel, JoinTable>, SourceKey>>;
100
+ unique(): UniqueQueryBuilder<Item>;
101
+ uniqueOrNull(): UniqueOrNullQueryBuilder<Item>;
102
+ first(): FirstQueryBuilder<Item>;
103
+ firstOrNull(): FirstOrNullQueryBuilder<Item>;
104
+ take(count: number): Promise<Item[]>;
105
+ paginate(opts: PaginationOptions): Promise<PaginatedResult<Item>>;
106
+ many(): ManyViaQueryBuilder<Item>;
107
+ };
108
+ type ValidViaTargetField<DataModel extends GenericDataModel, JoinTable extends AppTable<DataModel>, TargetTable extends AppTable<DataModel>> = {
109
+ [Field in Extract<keyof AppDoc<DataModel, JoinTable>, string>]: JoinTargetTable<DataModel, JoinTable, Field> extends TargetTable ? Field : never;
110
+ }[Extract<keyof AppDoc<DataModel, JoinTable>, string>];
111
+ type ViaIndexNamespace<DataModel extends GenericDataModel, TargetTable extends AppTable<DataModel>, JoinTable extends AppTable<DataModel>> = {
112
+ [IndexName in UserIndex<DataModel, JoinTable>]: {
113
+ (): ViaQueryFacade<DataModel, TargetTable, JoinTable>;
114
+ <const Value extends RootIndexValueArg<DataModel, JoinTable, IndexName>>(value: StrictRootIndexValueArg<DataModel, JoinTable, IndexName, Value>): ViaQueryFacade<DataModel, TargetTable, JoinTable>;
115
+ (selector: IndexSelector): ViaQueryFacade<DataModel, TargetTable, JoinTable>;
116
+ };
117
+ };
118
+ type TableNamespace<DataModel extends GenericDataModel, Table extends AppTable<DataModel>> = {
119
+ find<const Id extends GenericId<Table>>(id: Id): FindQueryBuilder<AppDoc<DataModel, Table>>;
120
+ findOrNull<const Id extends GenericId<Table>>(id: Id): FindOrNullQueryBuilder<AppDoc<DataModel, Table>>;
121
+ in<const Id extends GenericId<Table>>(ids: Id[]): TableBatchQueryFacade<DataModel, Table>;
122
+ via: <const JoinTable extends AppTable<DataModel>, const TargetField extends ValidViaTargetField<DataModel, JoinTable, Table>>(joinTable: JoinTable, targetField: TargetField) => ViaIndexNamespace<DataModel, Table, JoinTable>;
123
+ } & TableRangeQueryFacade<DataModel, Table> & {
124
+ [IndexName in TableIndexName<DataModel, Table>]: {
125
+ (selector: IndexSelector): TableQueryFacade<DataModel, Table>;
126
+ <const Value extends TableIndexValueArg<DataModel, Table, IndexName>>(value: StrictTableIndexValueArg<DataModel, Table, IndexName, Value>): TableQueryFacade<DataModel, Table>;
127
+ (): TableRangeQueryFacade<DataModel, Table>;
128
+ in<const Value extends TableIndexValueArg<DataModel, Table, IndexName>>(values: StrictTableIndexValueArg<DataModel, Table, IndexName, Value>[]): TableBatchQueryFacade<DataModel, Table>;
129
+ };
130
+ };
131
+ type QueryFacade<DataModel extends GenericDataModel> = {
132
+ [Table in AppTable<DataModel>]: TableNamespace<DataModel, Table>;
133
+ };
134
+ declare function createQueryFacade<DataModel extends GenericDataModel>(db: GenericDatabaseReader<DataModel>): QueryFacade<DataModel>;
135
+ declare function compute<Output = unknown>(load: () => Promise<Output> | Output): QueryNode<Output>;
136
+
137
+ export { type AppDoc, type AppTable, type QueryFacade, type RootIndexValueArg, type StrictRootIndexValueArg, type UserIndex, compute, createQueryFacade };
package/dist/index.js ADDED
@@ -0,0 +1,538 @@
1
+ // src/index.ts
2
+ var RESERVED_PROMISE_KEYS = /* @__PURE__ */ new Set(["then", "catch", "finally"]);
3
+ function createQueryNode(executeRoot) {
4
+ return {
5
+ _executeRoot: executeRoot,
6
+ then(onfulfilled, onrejected) {
7
+ return executeRoot().then(onfulfilled, onrejected);
8
+ }
9
+ };
10
+ }
11
+ async function expandDoc(parent, withBuilder) {
12
+ const spec = withBuilder(parent) ?? {};
13
+ const entries = await Promise.all(
14
+ Object.entries(spec).map(
15
+ async ([key, query]) => [key, await query._executeRoot()]
16
+ )
17
+ );
18
+ return {
19
+ ...parent,
20
+ ...Object.fromEntries(entries)
21
+ };
22
+ }
23
+ async function applyExpanders(item, expanders) {
24
+ let current = item;
25
+ for (const expander of expanders) {
26
+ current = await expandDoc(current, expander);
27
+ }
28
+ return current;
29
+ }
30
+ async function applyExpandersToMany(items, expanders) {
31
+ return await Promise.all(items.map((item) => applyExpanders(item, expanders)));
32
+ }
33
+ function buildQuery(makeQuery, modifiers) {
34
+ return modifiers.reduce((query, modifier) => modifier(query), makeQuery());
35
+ }
36
+ function createPlan(source) {
37
+ return {
38
+ source,
39
+ modifiers: [],
40
+ expanders: []
41
+ };
42
+ }
43
+ function withModifier(plan, modifier) {
44
+ return {
45
+ ...plan,
46
+ modifiers: [...plan.modifiers, modifier]
47
+ };
48
+ }
49
+ function withExpander(plan, expander) {
50
+ return {
51
+ ...plan,
52
+ expanders: [...plan.expanders, expander]
53
+ };
54
+ }
55
+ function withSourceKey(plan, sourceKey) {
56
+ return {
57
+ ...plan,
58
+ sourceKey
59
+ };
60
+ }
61
+ function normalizeIndexValues(index, value) {
62
+ if (isPlainObject(value)) {
63
+ return value;
64
+ }
65
+ return {
66
+ [inferFieldNameFromIndex(index)]: value
67
+ };
68
+ }
69
+ function isPlainObject(value) {
70
+ return typeof value === "object" && value !== null && !Array.isArray(value);
71
+ }
72
+ function applyIndexValues(query, values) {
73
+ let current = query;
74
+ for (const [field, value] of Object.entries(values)) {
75
+ current = current.eq(field, value);
76
+ }
77
+ return current;
78
+ }
79
+ function inferFieldNameFromIndex(index) {
80
+ if (index.startsWith("by") && index.length > 2) {
81
+ return `${index[2].toLowerCase()}${index.slice(3)}`;
82
+ }
83
+ throw new Error(`Cannot infer field name from index ${index}`);
84
+ }
85
+ function createIndexedQuery(db, table, index, selector) {
86
+ const baseQuery = db.query(table);
87
+ if (index === void 0) {
88
+ return baseQuery;
89
+ }
90
+ if (selector === void 0) {
91
+ return baseQuery.withIndex(index);
92
+ }
93
+ if (typeof selector === "function") {
94
+ return baseQuery.withIndex(index, selector);
95
+ }
96
+ if (index === "by_id") {
97
+ return baseQuery.withIndex(index, (q) => q.eq("_id", selector));
98
+ }
99
+ return baseQuery.withIndex(
100
+ index,
101
+ (q) => applyIndexValues(q, normalizeIndexValues(index, selector))
102
+ );
103
+ }
104
+ function createViaQuery(db, joinTable, index, selector) {
105
+ const baseQuery = db.query(joinTable);
106
+ if (selector === void 0) {
107
+ return baseQuery.withIndex(index);
108
+ }
109
+ if (typeof selector === "function") {
110
+ return baseQuery.withIndex(index, selector);
111
+ }
112
+ return baseQuery.withIndex(
113
+ index,
114
+ (q) => applyIndexValues(q, normalizeIndexValues(index, selector))
115
+ );
116
+ }
117
+ async function collectViaPairs(db, targetField, links) {
118
+ const pairs = await Promise.all(
119
+ links.map(async (link) => {
120
+ const id = link[targetField];
121
+ const doc = id ? await db.get(id) : null;
122
+ return doc ? { doc, link } : null;
123
+ })
124
+ );
125
+ return pairs.filter((pair) => pair !== null);
126
+ }
127
+ async function collectViaPairsUntil(db, targetField, query, count) {
128
+ const pairs = [];
129
+ for await (const link of query) {
130
+ const id = link[targetField];
131
+ if (!id) continue;
132
+ const doc = await db.get(id);
133
+ if (!doc) continue;
134
+ pairs.push({ doc, link });
135
+ if (pairs.length >= count) {
136
+ break;
137
+ }
138
+ }
139
+ return pairs;
140
+ }
141
+ function normalizeViaUniqueError(error, targetTable, joinTable, index) {
142
+ if (error instanceof Error && error.message === "unique() returned more than one result") {
143
+ return new Error(`Expected unique ${targetTable} via ${joinTable}.${index}`);
144
+ }
145
+ return error;
146
+ }
147
+ function createPlanRuntime(db, plan) {
148
+ const source = plan.source;
149
+ switch (source.kind) {
150
+ case "id":
151
+ return {
152
+ findOrNull: async () => await db.get(source.id),
153
+ mapOne: async (rawItem) => rawItem,
154
+ mapMany: async (rawItems) => rawItems,
155
+ missingMessages: {
156
+ find: `Could not find ${source.table} with id ${source.id}`
157
+ }
158
+ };
159
+ case "batch":
160
+ return {
161
+ many: async () => (await Promise.all(
162
+ source.values.map(
163
+ async (value) => await queryUniqueByIndex(db, source.table, source.index, value)
164
+ )
165
+ )).filter(
166
+ (doc) => doc !== null
167
+ ),
168
+ mapOne: async (rawItem) => rawItem,
169
+ mapMany: async (rawItems) => rawItems,
170
+ missingMessages: {}
171
+ };
172
+ case "query": {
173
+ const runQuery = () => buildQuery(
174
+ () => createIndexedQuery(db, source.table, source.index, source.selector),
175
+ plan.modifiers
176
+ );
177
+ return {
178
+ unique: async () => await runQuery().unique(),
179
+ first: async () => await runQuery().first(),
180
+ many: async () => await runQuery().collect(),
181
+ take: async (count) => await runQuery().take(count),
182
+ paginate: async (opts) => await runQuery().paginate(opts),
183
+ mapOne: async (rawItem) => rawItem,
184
+ mapMany: async (rawItems) => rawItems,
185
+ missingMessages: {
186
+ unique: source.index ? `Could not find ${source.table} with index ${source.index}` : `Could not find ${source.table}`,
187
+ first: `Could not find first ${source.table}`
188
+ }
189
+ };
190
+ }
191
+ case "via": {
192
+ const runQuery = () => buildQuery(
193
+ () => createViaQuery(db, source.joinTable, source.index, source.selector),
194
+ plan.modifiers
195
+ );
196
+ return {
197
+ unique: async () => {
198
+ const pairs = await collectViaPairsUntil(
199
+ db,
200
+ source.targetField,
201
+ runQuery(),
202
+ 2
203
+ );
204
+ if (pairs.length > 1) {
205
+ throw new Error("unique() returned more than one result");
206
+ }
207
+ return pairs[0] ?? null;
208
+ },
209
+ first: async () => {
210
+ const pairs = await collectViaPairsUntil(
211
+ db,
212
+ source.targetField,
213
+ runQuery(),
214
+ 1
215
+ );
216
+ return pairs[0] ?? null;
217
+ },
218
+ many: async () => await collectViaPairs(db, source.targetField, await runQuery().collect()),
219
+ take: async (count) => await collectViaPairs(db, source.targetField, await runQuery().take(count)),
220
+ paginate: async (opts) => {
221
+ const result = await runQuery().paginate(opts);
222
+ return {
223
+ page: await collectViaPairs(db, source.targetField, result.page),
224
+ isDone: result.isDone,
225
+ continueCursor: result.continueCursor
226
+ };
227
+ },
228
+ mapOne: async (rawItem) => rawItem.doc,
229
+ mapMany: async (rawItems) => rawItems.map((pair) => pair.doc),
230
+ missingMessages: {
231
+ unique: `Could not find ${source.targetTable} via ${source.joinTable}.${source.index}`,
232
+ first: `Could not find first ${source.targetTable} via ${source.joinTable}.${source.index}`
233
+ },
234
+ normalizeUniqueError: (error) => normalizeViaUniqueError(
235
+ error,
236
+ source.targetTable,
237
+ source.joinTable,
238
+ source.index
239
+ )
240
+ };
241
+ }
242
+ }
243
+ }
244
+ async function decorateItem(plan, rawItem, item) {
245
+ let output = item;
246
+ if (plan.source.kind === "via" && plan.sourceKey) {
247
+ output = { ...output, [plan.sourceKey]: rawItem.link };
248
+ }
249
+ if (plan.expanders.length > 0) {
250
+ output = await applyExpanders(output, plan.expanders);
251
+ }
252
+ return output;
253
+ }
254
+ async function decorateItems(plan, rawItems, items) {
255
+ let output = items;
256
+ if (plan.source.kind === "via" && plan.sourceKey) {
257
+ output = output.map((item, index) => ({
258
+ ...item,
259
+ [plan.sourceKey]: rawItems[index].link
260
+ }));
261
+ }
262
+ if (plan.expanders.length > 0) {
263
+ output = await applyExpandersToMany(output, plan.expanders);
264
+ }
265
+ return output;
266
+ }
267
+ async function executeFind(db, plan) {
268
+ const runtime = createPlanRuntime(db, plan);
269
+ const rawItem = await runtime.findOrNull?.();
270
+ if (rawItem == null) {
271
+ throw new Error(runtime.missingMessages.find);
272
+ }
273
+ return await decorateItem(plan, rawItem, await runtime.mapOne(rawItem));
274
+ }
275
+ async function executeFindOrNull(db, plan) {
276
+ const runtime = createPlanRuntime(db, plan);
277
+ const rawItem = await runtime.findOrNull?.();
278
+ if (rawItem == null) {
279
+ return null;
280
+ }
281
+ return await decorateItem(plan, rawItem, await runtime.mapOne(rawItem));
282
+ }
283
+ async function executeUnique(db, plan) {
284
+ const runtime = createPlanRuntime(db, plan);
285
+ let rawItem;
286
+ try {
287
+ rawItem = await runtime.unique?.();
288
+ } catch (error) {
289
+ throw runtime.normalizeUniqueError ? runtime.normalizeUniqueError(error) : error;
290
+ }
291
+ if (rawItem == null) {
292
+ throw new Error(runtime.missingMessages.unique);
293
+ }
294
+ return await decorateItem(plan, rawItem, await runtime.mapOne(rawItem));
295
+ }
296
+ async function executeUniqueOrNull(db, plan) {
297
+ const runtime = createPlanRuntime(db, plan);
298
+ let rawItem;
299
+ try {
300
+ rawItem = await runtime.unique?.();
301
+ } catch (error) {
302
+ throw runtime.normalizeUniqueError ? runtime.normalizeUniqueError(error) : error;
303
+ }
304
+ if (rawItem == null) {
305
+ return null;
306
+ }
307
+ return await decorateItem(plan, rawItem, await runtime.mapOne(rawItem));
308
+ }
309
+ async function executeFirst(db, plan) {
310
+ const runtime = createPlanRuntime(db, plan);
311
+ const rawItem = await runtime.first?.();
312
+ if (rawItem == null) {
313
+ throw new Error(runtime.missingMessages.first);
314
+ }
315
+ return await decorateItem(plan, rawItem, await runtime.mapOne(rawItem));
316
+ }
317
+ async function executeFirstOrNull(db, plan) {
318
+ const runtime = createPlanRuntime(db, plan);
319
+ const rawItem = await runtime.first?.();
320
+ if (rawItem == null) {
321
+ return null;
322
+ }
323
+ return await decorateItem(plan, rawItem, await runtime.mapOne(rawItem));
324
+ }
325
+ async function executeMany(db, plan) {
326
+ const runtime = createPlanRuntime(db, plan);
327
+ const rawItems = await runtime.many?.();
328
+ return await decorateItems(plan, rawItems ?? [], await runtime.mapMany(rawItems ?? []));
329
+ }
330
+ async function executeTake(db, plan, count) {
331
+ const runtime = createPlanRuntime(db, plan);
332
+ const rawItems = await runtime.take?.(count);
333
+ return await decorateItems(plan, rawItems ?? [], await runtime.mapMany(rawItems ?? []));
334
+ }
335
+ async function executePaginate(db, plan, opts) {
336
+ const runtime = createPlanRuntime(db, plan);
337
+ const result = await runtime.paginate?.(opts);
338
+ const rawItems = result?.page ?? [];
339
+ return {
340
+ page: await decorateItems(plan, rawItems, await runtime.mapMany(rawItems)),
341
+ isDone: result?.isDone ?? true,
342
+ continueCursor: result?.continueCursor ?? opts.cursor ?? ""
343
+ };
344
+ }
345
+ function createExpandableSingleFromPlan(db, plan, nullable) {
346
+ const execute = async () => nullable ? await executeFindOrNull(db, plan) : await executeFind(db, plan);
347
+ return {
348
+ ...createQueryNode(execute),
349
+ with(withBuilder) {
350
+ return createExpandableSingleFromPlan(
351
+ db,
352
+ withExpander(plan, withBuilder),
353
+ nullable
354
+ );
355
+ }
356
+ };
357
+ }
358
+ function createBatchFacade(db, plan) {
359
+ return {
360
+ with(withBuilder) {
361
+ return createBatchFacade(
362
+ db,
363
+ withExpander(plan, withBuilder)
364
+ );
365
+ },
366
+ many() {
367
+ return createQueryNode(async () => await executeMany(db, plan));
368
+ }
369
+ };
370
+ }
371
+ function createCollectionFacade(db, plan) {
372
+ const facade = {
373
+ with(withBuilder) {
374
+ return createCollectionFacade(
375
+ db,
376
+ withExpander(plan, withBuilder)
377
+ );
378
+ },
379
+ order(direction) {
380
+ return createCollectionFacade(
381
+ db,
382
+ withModifier(plan, (query) => query.order(direction))
383
+ );
384
+ },
385
+ filter(filterer) {
386
+ return createCollectionFacade(
387
+ db,
388
+ withModifier(plan, (query) => query.filter(filterer))
389
+ );
390
+ },
391
+ unique() {
392
+ return createQueryNode(async () => await executeUnique(db, plan));
393
+ },
394
+ uniqueOrNull() {
395
+ return createQueryNode(async () => await executeUniqueOrNull(db, plan));
396
+ },
397
+ first() {
398
+ return createQueryNode(async () => await executeFirst(db, plan));
399
+ },
400
+ firstOrNull() {
401
+ return createQueryNode(async () => await executeFirstOrNull(db, plan));
402
+ },
403
+ take(count) {
404
+ return executeTake(db, plan, count);
405
+ },
406
+ paginate(opts) {
407
+ return executePaginate(db, plan, opts);
408
+ },
409
+ many() {
410
+ return createQueryNode(async () => await executeMany(db, plan));
411
+ }
412
+ };
413
+ if (plan.source.kind === "via") {
414
+ facade.withSource = (key) => createCollectionFacade(db, withSourceKey(plan, key));
415
+ }
416
+ return facade;
417
+ }
418
+ function createIdPlan(table, id) {
419
+ return createPlan({
420
+ kind: "id",
421
+ table,
422
+ id
423
+ });
424
+ }
425
+ function createQueryPlan(table, index, selector) {
426
+ return createPlan({
427
+ kind: "query",
428
+ table,
429
+ index,
430
+ selector
431
+ });
432
+ }
433
+ function createBatchPlan(table, index, values) {
434
+ return createPlan({
435
+ kind: "batch",
436
+ table,
437
+ index,
438
+ values
439
+ });
440
+ }
441
+ function createViaPlan(targetTable, joinTable, targetField, index, selector) {
442
+ return createPlan({
443
+ kind: "via",
444
+ targetTable,
445
+ joinTable,
446
+ targetField,
447
+ index,
448
+ selector
449
+ });
450
+ }
451
+ function createTableNamespace(db, table) {
452
+ const rootFacade = createCollectionFacade(db, createQueryPlan(table));
453
+ const target = {
454
+ ...rootFacade,
455
+ find(id) {
456
+ return createExpandableSingleFromPlan(db, createIdPlan(table, id), false);
457
+ },
458
+ findOrNull(id) {
459
+ return createExpandableSingleFromPlan(db, createIdPlan(table, id), true);
460
+ },
461
+ in(ids) {
462
+ return createBatchFacade(db, createBatchPlan(table, "by_id", ids));
463
+ },
464
+ via(joinTable, targetField) {
465
+ return createViaNamespace(db, table, joinTable, targetField);
466
+ }
467
+ };
468
+ return new Proxy(target, {
469
+ get(currentTarget, prop, receiver) {
470
+ if (typeof prop !== "string" || prop in currentTarget) {
471
+ return Reflect.get(currentTarget, prop, receiver);
472
+ }
473
+ if (RESERVED_PROMISE_KEYS.has(prop) || prop === "all") {
474
+ return void 0;
475
+ }
476
+ const indexMethod = ((value) => createCollectionFacade(
477
+ db,
478
+ createQueryPlan(table, prop, value)
479
+ ));
480
+ indexMethod.in = (values) => createBatchFacade(
481
+ db,
482
+ createBatchPlan(table, prop, values)
483
+ );
484
+ return indexMethod;
485
+ }
486
+ });
487
+ }
488
+ function createViaNamespace(db, targetTable, joinTable, targetField) {
489
+ return new Proxy(
490
+ {},
491
+ {
492
+ get(_target, prop) {
493
+ if (typeof prop !== "string" || RESERVED_PROMISE_KEYS.has(prop)) {
494
+ return void 0;
495
+ }
496
+ return (value) => createCollectionFacade(
497
+ db,
498
+ createViaPlan(
499
+ targetTable,
500
+ joinTable,
501
+ targetField,
502
+ prop,
503
+ value
504
+ )
505
+ );
506
+ }
507
+ }
508
+ );
509
+ }
510
+ function createQueryFacade(db) {
511
+ return new Proxy(
512
+ {},
513
+ {
514
+ get(_target, prop) {
515
+ if (typeof prop !== "string" || RESERVED_PROMISE_KEYS.has(prop)) {
516
+ return void 0;
517
+ }
518
+ return createTableNamespace(db, prop);
519
+ }
520
+ }
521
+ );
522
+ }
523
+ function compute(load) {
524
+ return createQueryNode(async () => await load());
525
+ }
526
+ async function queryUniqueByIndex(db, table, index, value) {
527
+ if (index === "by_id") {
528
+ return await db.query(table).withIndex("by_id", (q) => q.eq("_id", value)).unique();
529
+ }
530
+ return await db.query(table).withIndex(
531
+ index,
532
+ (q) => applyIndexValues(q, normalizeIndexValues(index, value))
533
+ ).unique();
534
+ }
535
+ export {
536
+ compute,
537
+ createQueryFacade
538
+ };
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@davidtkramer/convex-relations",
3
+ "version": "0.1.0",
4
+ "description": "Typed query facade helpers for Convex backends",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/davidtkramer/convex-relations.git"
10
+ },
11
+ "homepage": "https://github.com/davidtkramer/convex-relations",
12
+ "bugs": {
13
+ "url": "https://github.com/davidtkramer/convex-relations/issues"
14
+ },
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "README.md",
21
+ "LICENSE"
22
+ ],
23
+ "exports": {
24
+ ".": {
25
+ "types": "./dist/index.d.ts",
26
+ "import": "./dist/index.js"
27
+ }
28
+ },
29
+ "main": "./dist/index.js",
30
+ "types": "./dist/index.d.ts",
31
+ "scripts": {
32
+ "build": "tsup src/index.ts --format esm --dts",
33
+ "check-types": "tsc --noEmit",
34
+ "test": "vitest run --typecheck"
35
+ },
36
+ "peerDependencies": {
37
+ "convex": "^1.25.4"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "^24.3.0",
41
+ "convex": "1.25.4",
42
+ "convex-test": "^0.0.41",
43
+ "tsup": "^8.5.0",
44
+ "typescript": "^5.9.2",
45
+ "vitest": "^4.0.15"
46
+ },
47
+ "packageManager": "pnpm@9.15.9"
48
+ }