@davidtkramer/convex-relations 0.1.0 → 0.4.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.
Files changed (4) hide show
  1. package/README.md +162 -102
  2. package/dist/index.d.ts +101 -57
  3. package/dist/index.js +342 -157
  4. package/package.json +2 -1
package/README.md CHANGED
@@ -1,49 +1,12 @@
1
- # `@davidtkramer/convex-relations`
1
+ <div align="center">
2
+ <img width="120" height="120" alt="image" src="https://github.com/user-attachments/assets/05619751-ea3a-4dd9-9bd7-3c231bd11d83" />
2
3
 
3
- Typed relations and query composition for Convex backends.
4
+ <h1>Convex Relations</h1>
5
+ </div>
4
6
 
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
- ```
7
+ `convex-relations` is a server-side query facade for Convex. It lets you write
8
+ data loading code as a typed result tree instead of manually coordinating
9
+ lookups, parallelization, and response shaping by hand.
47
10
 
48
11
  ## Example
49
12
 
@@ -67,8 +30,7 @@ export const getPost = query({
67
30
  }))
68
31
  .take(10),
69
32
  categories: ctx.q.categories
70
- .via("postCategories", "categoryId")
71
- .byPostId(post._id)
33
+ .through(ctx.q.postCategories.byPostId(post._id), "categoryId")
72
34
  .many(),
73
35
  }))
74
36
  .unique();
@@ -87,18 +49,18 @@ export const getPost = query({
87
49
  });
88
50
  ```
89
51
 
90
- This example shows most of the value proposition in one place:
52
+ This example shows the core model:
91
53
 
92
- - table-scoped access through `q.posts`, `q.comments`, `q.categories`
93
- - typed index lookup with `.bySlug(...)` and `.byPostId(...)`
54
+ - table-scoped access through `q.posts`, `q.comments`, and `q.categories`
55
+ - indexes as first-class query methods like `.bySlug(...)` and `.byPostId(...)`
94
56
  - 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
57
+ - reference traversal with `.through(...)`
58
+ - parallel nested loading within each `with(...)`
59
+ - a final strongly typed, API-ready result from one expression
98
60
 
99
61
  ## Equivalent Convex Code
100
62
 
101
- Without `convex-relations`, you end up assembling the same result shape by hand:
63
+ Without `convex-relations`, you end up assembling the same result shape by hand (or by agent 😉):
102
64
 
103
65
  ```ts
104
66
  const post = await ctx.db
@@ -150,7 +112,38 @@ That works, but you are responsible for:
150
112
  - remembering to manually `Promise.all(...)` nested relationships
151
113
  - traversing join tables by hand
152
114
  - assembling the final tree shape yourself for API responses
153
- - keeping the whole thing type-safe as it grows
115
+
116
+ ## Table of Contents
117
+
118
+ - [Installation](#installation)
119
+ - [Quick Start](#quick-start)
120
+ - [Core Concepts](#core-concepts)
121
+ - [API](#api)
122
+ - [Table Access Patterns](#table-access-patterns)
123
+ - [Relation Expansion with `with(...)`](#relation-expansion-with-with)
124
+ - [Reference Traversal with `through(...)`](#reference-traversal-with-through)
125
+ - [Terminals](#terminals)
126
+ - [Error Semantics](#error-semantics)
127
+ - [Performance Characteristics](#performance-characteristics)
128
+ - [Comparison to `convex-helpers/server/relationships`](#comparison-to-convex-helpersserverrelationships)
129
+
130
+ ## Installation
131
+
132
+ ```bash
133
+ npm install @davidtkramer/convex-relations
134
+ ```
135
+
136
+ ```bash
137
+ pnpm add @davidtkramer/convex-relations
138
+ ```
139
+
140
+ ```bash
141
+ bun add @davidtkramer/convex-relations
142
+ ```
143
+
144
+ ```bash
145
+ yarn add @davidtkramer/convex-relations
146
+ ```
154
147
 
155
148
  ## Quick Start
156
149
 
@@ -162,12 +155,13 @@ wrappers. A minimal setup looks like this:
162
155
  import { customCtx, customQuery } from "convex-helpers/server/customFunctions";
163
156
  import { query as baseQuery } from "./_generated/server";
164
157
  import type { DataModel } from "./_generated/dataModel";
158
+ import schema from "../schema";
165
159
  import { createQueryFacade } from "@davidtkramer/convex-relations";
166
160
 
167
161
  export const query = customQuery(
168
162
  baseQuery,
169
163
  customCtx((ctx: { db: any }) => ({
170
- q: createQueryFacade<DataModel>(ctx.db),
164
+ q: createQueryFacade<DataModel>(ctx.db, schema),
171
165
  })),
172
166
  );
173
167
  ```
@@ -220,36 +214,82 @@ comments: defineTable({
220
214
  const author = await ctx.q.authors.bySlug("ada-lovelace").unique();
221
215
  const comments = await ctx.q.comments.byPostId(postId).many();
222
216
  const approvedComments = await ctx.q.comments
223
- .byPostIdAndStatus({ postId, status: "approved" })
217
+ .byPostIdAndStatus(postId, "approved")
224
218
  .many();
225
219
  ```
226
220
 
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.
221
+ Single-field indexes accept a scalar. Compound indexes accept positional
222
+ arguments in index order. Zero-argument calls give you the indexed range so you
223
+ can filter, sort, paginate, or take a subset.
224
+
225
+ Because shorthand index methods like `.bySlug("...")` and
226
+ `.byPostIdAndStatus(...)` need the real indexed field names at runtime,
227
+ `createQueryFacade(...)` must be constructed with your Convex schema.
228
+
229
+ ### `with(...)` builds nested result shapes
230
+
231
+ `with(...)` lets you attach additional fields to every document in a query. The
232
+ callback receives the current document plus a small helper context, and returns
233
+ an object whose values can be plain sync values, other query nodes, or deferred
234
+ work via `defer(...)`.
235
+
236
+ ```ts
237
+ const post = await ctx.q.posts
238
+ .bySlug("hello-world")
239
+ .with((post, { defer }) => ({
240
+ author: ctx.q.authors.find(post.authorId),
241
+ comments: ctx.q.comments.byPostId(post._id).take(10),
242
+ readingTimeMinutes: defer(() =>
243
+ Math.ceil(post.body.split(/\s+/).length / 200),
244
+ ),
245
+ }))
246
+ .unique();
247
+ ```
248
+
249
+ That returns a single object shaped like:
250
+
251
+ ```ts
252
+ {
253
+ ...post,
254
+ author,
255
+ comments,
256
+ }
257
+ ```
258
+
259
+ This is the core idea behind the library: compose the data you want as a tree,
260
+ and `convex-relations` resolves and assembles that tree for you.
261
+
262
+ Within a single `with(...)`, sibling fields are resolved in parallel. If you
263
+ attach both `author` and `comments`, those branches start loading at the same
264
+ time. Nested `with(...)` calls preserve that behavior recursively, so each level
265
+ of the result tree parallelizes across its sibling fields.
230
266
 
231
267
  ## API
232
268
 
233
- ### `createQueryFacade<DataModel>(db)`
269
+ ### `createQueryFacade<DataModel>(db, schema)`
234
270
 
235
- Creates a typed facade over your Convex `db`.
271
+ Creates a typed facade over your Convex `db`. Pass the runtime schema from
272
+ `defineSchema(...)` so indexed shorthand methods can map scalar and tuple
273
+ arguments onto the correct Convex index fields.
236
274
 
237
275
  ```ts
238
276
  import { createQueryFacade } from "@davidtkramer/convex-relations";
239
277
  import type { DataModel } from "./_generated/dataModel";
278
+ import schema from "../schema";
240
279
 
241
- const q = createQueryFacade<DataModel>(ctx.db);
280
+ const q = createQueryFacade<DataModel>(ctx.db, schema);
242
281
  ```
243
282
 
244
- ### `compute(load)`
283
+ ### `with(..., { defer })`
245
284
 
246
- Wraps arbitrary async or sync work so it can be used inside `with(...)`.
285
+ The `with(...)` callback context includes `defer`, which wraps arbitrary async
286
+ or sync work into the same lazy result tree as your relation queries.
247
287
 
248
288
  ```ts
249
289
  const post = await q.posts
250
290
  .bySlug("hello-world")
251
- .with((post) => ({
252
- readingTimeMinutes: compute(() =>
291
+ .with((post, { defer }) => ({
292
+ readingTimeMinutes: defer(() =>
253
293
  Math.ceil(post.body.split(/\s+/).length / 200),
254
294
  ),
255
295
  }))
@@ -291,16 +331,16 @@ Single-field indexes accept a scalar:
291
331
  const author = await q.authors.bySlug("ada-lovelace").unique();
292
332
  ```
293
333
 
294
- Compound indexes accept an object containing a valid prefix:
334
+ Compound indexes accept leading positional arguments:
295
335
 
296
336
  ```ts
297
337
  const comments = await q.comments
298
- .byPostIdAndCreatedAt({ postId })
338
+ .byPostIdAndStatus(postId)
299
339
  .order("desc")
300
340
  .take(20);
301
341
 
302
342
  const exactOrPrefix = await q.comments
303
- .byPostIdAndCreatedAt({ postId, createdAt: 1700000000000 })
343
+ .byPostIdAndStatus(postId, "approved")
304
344
  .many();
305
345
  ```
306
346
 
@@ -309,8 +349,8 @@ const exactOrPrefix = await q.comments
309
349
  You can also pass Convex's index selector callback:
310
350
 
311
351
  ```ts
312
- const recentComments = await q.comments
313
- .byPostIdAndCreatedAt((q) => q.eq("postId", postId).gt("createdAt", cutoff))
352
+ const approvedComments = await q.comments
353
+ .byPostIdAndStatus((q) => q.eq("postId", postId).eq("status", "approved"))
314
354
  .many();
315
355
  ```
316
356
 
@@ -335,10 +375,10 @@ Batch lookups skip missing rows.
335
375
  ```ts
336
376
  const post = await q.posts
337
377
  .bySlug("hello-world")
338
- .with((post) => ({
378
+ .with((post, { defer }) => ({
339
379
  author: q.authors.find(post.authorId),
340
380
  comments: q.comments.byPostId(post._id).order("desc").take(10),
341
- commentCount: compute(async () => {
381
+ commentCount: defer(async () => {
342
382
  const comments = await q.comments.byPostId(post._id).many();
343
383
  return comments.length;
344
384
  }),
@@ -362,33 +402,66 @@ const post = await q.posts
362
402
 
363
403
  Each `with(...)` stage sees fields added by earlier stages.
364
404
 
365
- ## Join Table Traversal with `via(...)`
405
+ ## Reference Traversal with `through(...)`
366
406
 
367
- Use `via(joinTable, targetField)` for many-to-many relationships.
407
+ Use `through(sourceQuery, foreignKeyField)` when another query already produces
408
+ rows that point at the table you want.
368
409
 
369
410
  Given `postCategories { postId, categoryId }`, you can fetch categories for a post:
370
411
 
371
412
  ```ts
372
413
  const categories = await q.categories
373
- .via("postCategories", "categoryId")
374
- .byPostId(postId)
414
+ .through(q.postCategories.byPostId(postId), "categoryId")
375
415
  .many();
376
416
  ```
377
417
 
378
- You can also attach the join row with `withSource(...)`:
418
+ This is essentially syntactic sugar for "run the source query, extract ids from
419
+ that field, then load the target rows for you."
420
+
421
+ You can also attach the source row through the normal `with(...)` callback:
379
422
 
380
423
  ```ts
381
424
  const categories = await q.categories
382
- .via("postCategories", "categoryId")
383
- .byPostId(postId)
384
- .withSource("link")
425
+ .through(q.postCategories.byPostId(postId).order("desc"), "categoryId")
426
+ .with((category, { source }) => ({ link: source }))
385
427
  .many();
386
428
 
387
429
  categories[0]?.link.postId;
388
430
  categories[0]?.link.categoryId;
389
431
  ```
390
432
 
391
- This is useful when the join table stores metadata like ordering, role, or timestamps.
433
+ This is useful when the source table stores metadata like ordering, role, or
434
+ timestamps.
435
+
436
+ `through(...)` is not limited to join tables. Any compatible source query works:
437
+
438
+ ```ts
439
+ const author = await q.authors
440
+ .through(q.posts.bySlug("hello-world"), "authorId")
441
+ .with((author, { source }) => ({ post: source }))
442
+ .unique();
443
+
444
+ author.post.slug;
445
+ ```
446
+
447
+ Source-query shaping lives inside the `through(...)` argument:
448
+
449
+ ```ts
450
+ const tags = await q.tags
451
+ .through(
452
+ q.postTags
453
+ .byPostId(postId)
454
+ .filter((query) => query.eq(query.field("kind"), "primary"))
455
+ .order("desc")
456
+ .take(10),
457
+ "tagId",
458
+ )
459
+ .with((tag, { source }) => ({ link: source }))
460
+ .many();
461
+ ```
462
+
463
+ After `through(...)`, you can keep shaping the target result with `with(...)`
464
+ and then choose a terminal like `many()` or `first()`.
392
465
 
393
466
  ## Terminals
394
467
 
@@ -447,7 +520,6 @@ const page = await q.posts.byAuthorId(authorId).paginate({
447
520
  - `unique()` also throws if there are multiple matches
448
521
  - `first()` throws if there is no match
449
522
  - `findOrNull()`, `uniqueOrNull()`, and `firstOrNull()` return `null` instead
450
- - `via(...).unique()` normalizes its duplicate error to include the target table and join index
451
523
 
452
524
  ## Performance Characteristics
453
525
 
@@ -456,13 +528,12 @@ const page = await q.posts.byAuthorId(authorId).paginate({
456
528
  Within a single `with(...)` stage, every field in the returned object runs in parallel.
457
529
 
458
530
  ```ts
459
- const post = await q.posts.find(postId).with((post) => ({
531
+ const post = await q.posts.find(postId).with((post, { defer }) => ({
460
532
  author: q.authors.find(post.authorId),
461
533
  comments: q.comments.byPostId(post._id).take(10),
462
- categoryCount: compute(async () => {
534
+ categoryCount: defer(async () => {
463
535
  const categories = await q.categories
464
- .via("postCategories", "categoryId")
465
- .byPostId(post._id)
536
+ .through(q.postCategories.byPostId(post._id), "categoryId")
466
537
  .many();
467
538
  return categories.length;
468
539
  }),
@@ -489,7 +560,9 @@ q.posts
489
560
 
490
561
  The second stage waits for the first stage, because it depends on fields added earlier.
491
562
 
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.
563
+ `through(...)` currently resolves target documents by fetching the source rows
564
+ first, then loading each target document individually. This is correct and
565
+ predictable, but it is not a single batched join at the database level.
493
566
 
494
567
  ### Practical guidance
495
568
 
@@ -507,8 +580,7 @@ This:
507
580
 
508
581
  ```ts
509
582
  const categories = await q.categories
510
- .via("postCategories", "categoryId")
511
- .byPostId(postId)
583
+ .through(q.postCategories.byPostId(postId), "categoryId")
512
584
  .many();
513
585
  ```
514
586
 
@@ -525,15 +597,3 @@ const categories = await getManyVia(
525
597
  ```
526
598
 
527
599
  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
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { GenericDataModel, TableNamesInDataModel, DocumentByName, IndexNames, NamedTableInfo, NamedIndex, GenericDatabaseReader } from 'convex/server';
1
+ import { GenericDataModel, TableNamesInDataModel, DocumentByName, GenericTableInfo, FilterBuilder, ExpressionOrValue, NamedTableInfo, IndexNames, IndexRangeBuilder, NamedIndex, IndexRange, GenericDatabaseReader } from 'convex/server';
2
2
  import { GenericId } from 'convex/values';
3
3
 
4
4
  type Simplify<T> = {
@@ -8,38 +8,55 @@ type AppTable<DataModel extends GenericDataModel> = TableNamesInDataModel<DataMo
8
8
  type AppDoc<DataModel extends GenericDataModel, Table extends AppTable<DataModel>> = DocumentByName<DataModel, Table>;
9
9
  type UserIndex<DataModel extends GenericDataModel, Table extends AppTable<DataModel>> = Exclude<IndexNames<NamedTableInfo<DataModel, Table>>, 'by_creation_time'> & string;
10
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,
11
+ type StripCreationTime<Fields extends readonly string[]> = Fields extends readonly [
12
+ ...infer Rest extends readonly string[],
14
13
  '_creationTime'
14
+ ] ? Rest : Fields;
15
+ type UserIndexFields<DataModel extends GenericDataModel, Table extends AppTable<DataModel>, IndexName extends UserIndex<DataModel, Table>> = StripCreationTime<RawIndexFields<DataModel, Table, IndexName>>;
16
+ type SingleIndexField<DataModel extends GenericDataModel, Table extends AppTable<DataModel>, IndexName extends UserIndex<DataModel, Table>> = UserIndexFields<DataModel, Table, IndexName> extends readonly [
17
+ infer Field extends string
15
18
  ] ? Field : never;
16
- type TuplePrefixValues<DataModel extends GenericDataModel, Table extends AppTable<DataModel>, Fields extends readonly string[], Seen extends readonly string[] = []> = Fields extends readonly [
19
+ type TuplePrefixArgs<DataModel extends GenericDataModel, Table extends AppTable<DataModel>, Fields extends readonly string[], Seen extends readonly string[] = [], SeenValues extends readonly unknown[] = []> = Fields extends readonly [
17
20
  infer Head extends string,
18
21
  ...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;
22
+ ] ? [...SeenValues, AppDoc<DataModel, Table>[Head]] | TuplePrefixArgs<DataModel, Table, Tail, [
23
+ ...Seen,
24
+ Head
25
+ ], [
26
+ ...SeenValues,
27
+ AppDoc<DataModel, Table>[Head]
28
+ ]> : never;
29
+ type PositionalIndexArgs<DataModel extends GenericDataModel, Table extends AppTable<DataModel>, IndexName extends UserIndex<DataModel, Table>> = TuplePrefixArgs<DataModel, Table, UserIndexFields<DataModel, Table, IndexName>>;
30
+ type RootIndexValueArg<DataModel extends GenericDataModel, Table extends AppTable<DataModel>, IndexName extends UserIndex<DataModel, Table>> = PositionalIndexArgs<DataModel, Table, IndexName> | (SingleIndexField<DataModel, Table, IndexName> extends never ? never : AppDoc<DataModel, Table>[SingleIndexField<DataModel, Table, IndexName>]);
31
+ type StrictRootIndexValueArg<DataModel extends GenericDataModel, Table extends AppTable<DataModel>, IndexName extends UserIndex<DataModel, Table>, Value extends RootIndexValueArg<DataModel, Table, IndexName>> = Value;
25
32
  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;
33
+ type UserIndexArg<DataModel extends GenericDataModel, Table extends AppTable<DataModel>, IndexName extends UserIndex<DataModel, Table>> = SingleIndexField<DataModel, Table, IndexName> extends never ? PositionalIndexArgs<DataModel, Table, IndexName> : AppDoc<DataModel, Table>[SingleIndexField<DataModel, Table, IndexName>];
34
+ 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> ? UserIndexArg<DataModel, Table, IndexName> : never;
35
+ type StrictTableIndexValueArg<DataModel extends GenericDataModel, Table extends AppTable<DataModel>, IndexName extends TableIndexName<DataModel, Table>, Value extends TableIndexValueArg<DataModel, Table, IndexName>> = Value;
36
+ type TableIndexInvocationArgs<DataModel extends GenericDataModel, Table extends AppTable<DataModel>, IndexName extends TableIndexName<DataModel, Table>> = IndexName extends 'by_id' ? [GenericId<Table>] : IndexName extends UserIndex<DataModel, Table> ? PositionalIndexArgs<DataModel, Table, IndexName> : never;
28
37
  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
38
  type QueryNode<Output> = PromiseLike<Output> & {
31
39
  readonly _executeRoot: () => Promise<Output>;
32
40
  };
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>;
41
+ type TableInfo<DataModel extends GenericDataModel, Table extends AppTable<DataModel>> = NamedTableInfo<DataModel, Table>;
42
+ type QueryPlanHandle<DataModel extends GenericDataModel, Table extends AppTable<DataModel>> = {
43
+ readonly _plan: QueryPlan;
44
+ readonly _table: Table;
45
+ };
46
+ type WithSpec = Record<string, unknown>;
47
+ type DeferredBuilder = <Output>(load: () => Promise<Output> | Output) => QueryNode<Output>;
48
+ type BaseWithContext = {
49
+ defer: DeferredBuilder;
50
+ };
51
+ type WithContext<SourceItem = never> = BaseWithContext & ([SourceItem] extends [never] ? {} : {
52
+ source: SourceItem;
53
+ });
54
+ type WithBuilder<ParentItem, Context = BaseWithContext, Spec extends WithSpec | undefined = WithSpec | undefined> = (parent: ParentItem, context: Context) => Spec;
55
+ type AnyWithBuilder<ParentItem, Context = BaseWithContext> = WithBuilder<ParentItem, Context, WithSpec | undefined>;
36
56
  type BuiltWithSpec<Builder> = Builder extends (...args: any[]) => infer Spec ? Spec : never;
37
57
  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;
58
+ [K in keyof BuiltWithSpec<Builder>]: BuiltWithSpec<Builder>[K] extends QueryNode<infer Output> ? Output : BuiltWithSpec<Builder>[K];
39
59
  } : {})>;
40
- type AttachSource<ParentItem, SourceItem, SourceKey extends string> = Simplify<ParentItem & {
41
- [K in SourceKey]: SourceItem;
42
- }>;
43
60
  type PaginationOptions = {
44
61
  numItems: number;
45
62
  cursor: string | null;
@@ -49,81 +66,89 @@ type PaginatedResult<Item> = {
49
66
  isDone: boolean;
50
67
  continueCursor: string;
51
68
  };
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> & {
69
+ type QueryFilter<TableInfo extends GenericTableInfo> = (q: FilterBuilder<TableInfo>) => ExpressionOrValue<boolean>;
70
+ type QueryModifier = (query: any) => any;
71
+ type IndexSelector<DataModel extends GenericDataModel, Table extends AppTable<DataModel>, IndexName extends UserIndex<DataModel, Table>> = (q: IndexRangeBuilder<AppDoc<DataModel, Table>, NamedIndex<TableInfo<DataModel, Table>, IndexName>>) => IndexRange;
72
+ type SingleNodeKind<Nullable extends boolean> = Nullable extends true ? 'nullableSingle' : 'single';
73
+ type SingleQueryBuilder<Item, Nullable extends boolean> = QueryNode<Nullable extends true ? Item | null : Item> & ThroughNodeHandle<Item, SingleNodeKind<Nullable>>;
74
+ type ExpandableSingleQueryBuilder<Item, Nullable extends boolean> = SingleQueryBuilder<Item, Nullable> & {
55
75
  with<Builder extends AnyWithBuilder<Item>>(withBuilder: Builder): ExpandableSingleQueryBuilder<ExpandWith<Item, Builder>, Nullable>;
56
76
  };
57
- type SingleQueryBuilder<Item, Nullable extends boolean> = QueryNode<Nullable extends true ? Item | null : Item>;
58
77
  type UniqueQueryBuilder<Item> = SingleQueryBuilder<Item, false>;
59
78
  type UniqueOrNullQueryBuilder<Item> = SingleQueryBuilder<Item, true>;
60
79
  type FirstQueryBuilder<Item> = SingleQueryBuilder<Item, false>;
61
80
  type FirstOrNullQueryBuilder<Item> = SingleQueryBuilder<Item, true>;
62
81
  type FindQueryBuilder<Item> = ExpandableSingleQueryBuilder<Item, false>;
63
82
  type FindOrNullQueryBuilder<Item> = ExpandableSingleQueryBuilder<Item, true>;
64
- type ManyQueryBuilder<Item> = QueryNode<Item[]>;
65
- type BatchQueryBuilder<Item> = QueryNode<Item[]>;
66
- type ManyViaQueryBuilder<Item> = QueryNode<Item[]>;
83
+ type ManyQueryBuilder<Item> = QueryNode<Item[]> & ThroughNodeHandle<Item, 'many'>;
84
+ type BatchQueryBuilder<Item> = ManyQueryBuilder<Item>;
85
+ type ThroughSourceNodeKind = 'many' | 'single' | 'nullableSingle';
86
+ type ThroughNodeHandle<SourceItem, Kind extends ThroughSourceNodeKind> = {
87
+ readonly _throughSourceKind: Kind;
88
+ readonly _throughSourceType?: SourceItem;
89
+ };
90
+ type AnyManySourceNode<SourceItem> = QueryNode<SourceItem[]> & ThroughNodeHandle<SourceItem, 'many'>;
91
+ type AnySingleSourceNode<SourceItem, Nullable extends boolean> = QueryNode<Nullable extends true ? SourceItem | null : SourceItem> & ThroughNodeHandle<SourceItem, SingleNodeKind<Nullable>>;
92
+ type ThroughSourceField<DataModel extends GenericDataModel, TargetTable extends AppTable<DataModel>, SourceItem> = {
93
+ [Field in Extract<keyof SourceItem, string>]: IdTargetTable<DataModel, SourceItem[Field]> extends TargetTable ? Field : never;
94
+ }[Extract<keyof SourceItem, string>];
95
+ type ManyThroughQueryBuilder<Item, SourceItem = unknown> = QueryNode<Item[]> & ThroughNodeHandle<SourceItem, 'many'> & {
96
+ with<Builder extends AnyWithBuilder<Item, WithContext<SourceItem>>>(withBuilder: Builder): ManyThroughQueryBuilder<ExpandWith<Item, Builder>, SourceItem>;
97
+ };
98
+ type SingleThroughQueryBuilder<Item, SourceItem, Nullable extends boolean> = QueryNode<Nullable extends true ? Item | null : Item> & ThroughNodeHandle<SourceItem, Nullable extends true ? 'nullableSingle' : 'single'> & {
99
+ with<Builder extends AnyWithBuilder<Item, WithContext<SourceItem>>>(withBuilder: Builder): SingleThroughQueryBuilder<ExpandWith<Item, Builder>, SourceItem, Nullable>;
100
+ };
67
101
  type TableQueryFacade<DataModel extends GenericDataModel, Table extends AppTable<DataModel>, Item = AppDoc<DataModel, Table>> = {
68
102
  with<Builder extends AnyWithBuilder<Item>>(withBuilder: Builder): TableQueryFacade<DataModel, Table, ExpandWith<Item, Builder>>;
69
103
  order(direction: 'asc' | 'desc'): TableQueryFacade<DataModel, Table, Item>;
70
- filter(filterer: QueryFilter): TableQueryFacade<DataModel, Table, Item>;
104
+ filter(filterer: QueryFilter<TableInfo<DataModel, Table>>): TableQueryFacade<DataModel, Table, Item>;
71
105
  unique(): UniqueQueryBuilder<Item>;
72
106
  uniqueOrNull(): UniqueOrNullQueryBuilder<Item>;
73
107
  first(): FirstQueryBuilder<Item>;
74
108
  firstOrNull(): FirstOrNullQueryBuilder<Item>;
75
- take(count: number): Promise<Item[]>;
109
+ take(count: number): ManyQueryBuilder<Item>;
76
110
  paginate(opts: PaginationOptions): Promise<PaginatedResult<Item>>;
77
111
  many(): ManyQueryBuilder<Item>;
78
- };
112
+ } & QueryPlanHandle<DataModel, Table>;
79
113
  type TableRangeQueryFacade<DataModel extends GenericDataModel, Table extends AppTable<DataModel>, Item = AppDoc<DataModel, Table>> = {
80
114
  with<Builder extends AnyWithBuilder<Item>>(withBuilder: Builder): TableRangeQueryFacade<DataModel, Table, ExpandWith<Item, Builder>>;
81
115
  order(direction: 'asc' | 'desc'): TableRangeQueryFacade<DataModel, Table, Item>;
82
- filter(filterer: QueryFilter): TableRangeQueryFacade<DataModel, Table, Item>;
116
+ filter(filterer: QueryFilter<TableInfo<DataModel, Table>>): TableRangeQueryFacade<DataModel, Table, Item>;
83
117
  unique(): UniqueQueryBuilder<Item>;
84
118
  uniqueOrNull(): UniqueOrNullQueryBuilder<Item>;
85
119
  first(): FirstQueryBuilder<Item>;
86
120
  firstOrNull(): FirstOrNullQueryBuilder<Item>;
87
- take(count: number): Promise<Item[]>;
121
+ take(count: number): ManyQueryBuilder<Item>;
88
122
  paginate(opts: PaginationOptions): Promise<PaginatedResult<Item>>;
89
123
  many(): ManyQueryBuilder<Item>;
90
- };
124
+ } & QueryPlanHandle<DataModel, Table>;
91
125
  type TableBatchQueryFacade<DataModel extends GenericDataModel, Table extends AppTable<DataModel>, Item = AppDoc<DataModel, Table>> = {
92
126
  with<Builder extends AnyWithBuilder<Item>>(withBuilder: Builder): TableBatchQueryFacade<DataModel, Table, ExpandWith<Item, Builder>>;
93
127
  many(): BatchQueryBuilder<Item>;
94
128
  };
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>>;
129
+ type ThroughQueryFacade<DataModel extends GenericDataModel, TargetTable extends AppTable<DataModel>, SourceItem, Item = AppDoc<DataModel, TargetTable>> = {
130
+ with<Builder extends AnyWithBuilder<Item, WithContext<SourceItem>>>(withBuilder: Builder): ThroughQueryFacade<DataModel, TargetTable, SourceItem, ExpandWith<Item, Builder>>;
100
131
  unique(): UniqueQueryBuilder<Item>;
101
132
  uniqueOrNull(): UniqueOrNullQueryBuilder<Item>;
102
133
  first(): FirstQueryBuilder<Item>;
103
134
  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
- };
135
+ many(): ManyQueryBuilder<Item>;
117
136
  };
137
+ type ThroughCollectionSource<DataModel extends GenericDataModel, SourceTable extends AppTable<DataModel>, SourceItem = AppDoc<DataModel, SourceTable>> = TableQueryFacade<DataModel, SourceTable, SourceItem> | TableRangeQueryFacade<DataModel, SourceTable, SourceItem>;
118
138
  type TableNamespace<DataModel extends GenericDataModel, Table extends AppTable<DataModel>> = {
119
139
  find<const Id extends GenericId<Table>>(id: Id): FindQueryBuilder<AppDoc<DataModel, Table>>;
120
140
  findOrNull<const Id extends GenericId<Table>>(id: Id): FindOrNullQueryBuilder<AppDoc<DataModel, Table>>;
121
141
  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>;
142
+ through: {
143
+ <const SourceTable extends AppTable<DataModel>, SourceItem, const TargetField extends ThroughSourceField<DataModel, Table, SourceItem>>(sourceQuery: ThroughCollectionSource<DataModel, SourceTable, SourceItem>, targetField: TargetField): ThroughQueryFacade<DataModel, Table, SourceItem>;
144
+ <SourceItem, const TargetField extends ThroughSourceField<DataModel, Table, SourceItem>>(sourceQuery: AnyManySourceNode<SourceItem>, targetField: TargetField): ManyThroughQueryBuilder<AppDoc<DataModel, Table>, SourceItem>;
145
+ <SourceItem, const TargetField extends ThroughSourceField<DataModel, Table, SourceItem>>(sourceQuery: AnySingleSourceNode<SourceItem, false>, targetField: TargetField): SingleThroughQueryBuilder<AppDoc<DataModel, Table>, SourceItem, false>;
146
+ <SourceItem, const TargetField extends ThroughSourceField<DataModel, Table, SourceItem>>(sourceQuery: AnySingleSourceNode<SourceItem, true>, targetField: TargetField): SingleThroughQueryBuilder<AppDoc<DataModel, Table>, SourceItem, true>;
147
+ };
123
148
  } & TableRangeQueryFacade<DataModel, Table> & {
124
149
  [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>;
150
+ (selector: IndexName extends UserIndex<DataModel, Table> ? IndexSelector<DataModel, Table, IndexName> : never): TableQueryFacade<DataModel, Table>;
151
+ <const Args extends TableIndexInvocationArgs<DataModel, Table, IndexName>>(...args: Args): TableQueryFacade<DataModel, Table>;
127
152
  (): TableRangeQueryFacade<DataModel, Table>;
128
153
  in<const Value extends TableIndexValueArg<DataModel, Table, IndexName>>(values: StrictTableIndexValueArg<DataModel, Table, IndexName, Value>[]): TableBatchQueryFacade<DataModel, Table>;
129
154
  };
@@ -131,7 +156,26 @@ type TableNamespace<DataModel extends GenericDataModel, Table extends AppTable<D
131
156
  type QueryFacade<DataModel extends GenericDataModel> = {
132
157
  [Table in AppTable<DataModel>]: TableNamespace<DataModel, Table>;
133
158
  };
159
+ type QuerySourcePlan = {
160
+ kind: 'id';
161
+ table: string;
162
+ id: GenericId<any>;
163
+ } | {
164
+ kind: 'query';
165
+ table: string;
166
+ index?: string;
167
+ selector?: unknown;
168
+ } | {
169
+ kind: 'batch';
170
+ table: string;
171
+ index: string;
172
+ values: unknown[];
173
+ };
174
+ type QueryPlan = {
175
+ source: QuerySourcePlan;
176
+ modifiers: QueryModifier[];
177
+ expanders: AnyWithBuilder<any, any>[];
178
+ };
134
179
  declare function createQueryFacade<DataModel extends GenericDataModel>(db: GenericDatabaseReader<DataModel>): QueryFacade<DataModel>;
135
- declare function compute<Output = unknown>(load: () => Promise<Output> | Output): QueryNode<Output>;
136
180
 
137
- export { type AppDoc, type AppTable, type QueryFacade, type RootIndexValueArg, type StrictRootIndexValueArg, type UserIndex, compute, createQueryFacade };
181
+ export { type AppDoc, type AppTable, type QueryFacade, type RootIndexValueArg, type StrictRootIndexValueArg, type UserIndex, createQueryFacade };
package/dist/index.js CHANGED
@@ -8,31 +8,46 @@ function createQueryNode(executeRoot) {
8
8
  }
9
9
  };
10
10
  }
11
- async function expandDoc(parent, withBuilder) {
12
- const spec = withBuilder(parent) ?? {};
11
+ function createDeferredNode(load) {
12
+ return createQueryNode(async () => await load());
13
+ }
14
+ function isQueryNode(value) {
15
+ return typeof value === "object" && value !== null && "_executeRoot" in value && typeof value._executeRoot === "function";
16
+ }
17
+ async function expandDoc(parent, withBuilder, context) {
18
+ const spec = withBuilder(parent, context) ?? {};
13
19
  const entries = await Promise.all(
14
- Object.entries(spec).map(
15
- async ([key, query]) => [key, await query._executeRoot()]
16
- )
20
+ Object.entries(spec).map(async ([key, value]) => [
21
+ key,
22
+ isQueryNode(value) ? await value._executeRoot() : value
23
+ ])
17
24
  );
18
25
  return {
19
26
  ...parent,
20
27
  ...Object.fromEntries(entries)
21
28
  };
22
29
  }
23
- async function applyExpanders(item, expanders) {
30
+ async function applyExpanders(item, expanders, context) {
24
31
  let current = item;
25
32
  for (const expander of expanders) {
26
- current = await expandDoc(current, expander);
33
+ current = await expandDoc(current, expander, context);
27
34
  }
28
35
  return current;
29
36
  }
30
- async function applyExpandersToMany(items, expanders) {
31
- return await Promise.all(items.map((item) => applyExpanders(item, expanders)));
37
+ async function applyExpandersToMany(items, expanders, getContext) {
38
+ return await Promise.all(
39
+ items.map((item, index) => applyExpanders(item, expanders, getContext(item, index)))
40
+ );
32
41
  }
33
42
  function buildQuery(makeQuery, modifiers) {
34
43
  return modifiers.reduce((query, modifier) => modifier(query), makeQuery());
35
44
  }
45
+ function createWithContext(source) {
46
+ return {
47
+ defer: createDeferredNode,
48
+ ...source === void 0 ? {} : { source }
49
+ };
50
+ }
36
51
  function createPlan(source) {
37
52
  return {
38
53
  source,
@@ -52,13 +67,11 @@ function withExpander(plan, expander) {
52
67
  expanders: [...plan.expanders, expander]
53
68
  };
54
69
  }
55
- function withSourceKey(plan, sourceKey) {
56
- return {
57
- ...plan,
58
- sourceKey
59
- };
60
- }
61
70
  function normalizeIndexValues(index, value) {
71
+ if (Array.isArray(value)) {
72
+ const fieldNames = inferFieldNamesFromIndex(index);
73
+ return Object.fromEntries(fieldNames.map((field, index2) => [field, value[index2]]));
74
+ }
62
75
  if (isPlainObject(value)) {
63
76
  return value;
64
77
  }
@@ -77,8 +90,14 @@ function applyIndexValues(query, values) {
77
90
  return current;
78
91
  }
79
92
  function inferFieldNameFromIndex(index) {
93
+ return inferFieldNamesFromIndex(index)[0];
94
+ }
95
+ function inferFieldNamesFromIndex(index) {
96
+ if (index === "by_id") {
97
+ return ["_id"];
98
+ }
80
99
  if (index.startsWith("by") && index.length > 2) {
81
- return `${index[2].toLowerCase()}${index.slice(3)}`;
100
+ return index.slice(2).split("And").map((part) => `${part[0].toLowerCase()}${part.slice(1)}`);
82
101
  }
83
102
  throw new Error(`Cannot infer field name from index ${index}`);
84
103
  }
@@ -101,49 +120,61 @@ function createIndexedQuery(db, table, index, selector) {
101
120
  (q) => applyIndexValues(q, normalizeIndexValues(index, selector))
102
121
  );
103
122
  }
104
- function createViaQuery(db, joinTable, index, selector) {
105
- const baseQuery = db.query(joinTable);
106
- if (selector === void 0) {
107
- return baseQuery.withIndex(index);
123
+ function sourceDescription(plan) {
124
+ switch (plan.source.kind) {
125
+ case "id":
126
+ return `${plan.source.table} with id ${plan.source.id}`;
127
+ case "batch":
128
+ return `${plan.source.table} via ${plan.source.index}`;
129
+ case "query":
130
+ return plan.source.index ? `${plan.source.table} with index ${plan.source.index}` : plan.source.table;
108
131
  }
109
- if (typeof selector === "function") {
110
- return baseQuery.withIndex(index, selector);
132
+ }
133
+ async function resolveThroughPair(db, targetField, source) {
134
+ const id = source[targetField];
135
+ if (!id) {
136
+ return null;
111
137
  }
112
- return baseQuery.withIndex(
113
- index,
114
- (q) => applyIndexValues(q, normalizeIndexValues(index, selector))
115
- );
138
+ const target = await db.get(id);
139
+ return target ? { source, target } : null;
116
140
  }
117
- async function collectViaPairs(db, targetField, links) {
141
+ async function collectThroughPairs(db, targetField, sourceItems) {
118
142
  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
- })
143
+ sourceItems.map(async (source) => await resolveThroughPair(db, targetField, source))
124
144
  );
125
145
  return pairs.filter((pair) => pair !== null);
126
146
  }
127
- async function collectViaPairsUntil(db, targetField, query, count) {
147
+ async function* iterateDecoratedSourceItems(db, plan) {
148
+ if (plan.source.kind !== "query") {
149
+ for (const item of await executeMany(db, plan)) {
150
+ yield item;
151
+ }
152
+ return;
153
+ }
154
+ const runtime = createPlanRuntime(db, plan);
155
+ const source = plan.source;
156
+ const query = buildQuery(
157
+ () => createIndexedQuery(db, source.table, source.index, source.selector),
158
+ plan.modifiers
159
+ );
160
+ for await (const rawItem of query) {
161
+ yield await decorateItem(plan, rawItem, await runtime.mapOne(rawItem));
162
+ }
163
+ }
164
+ async function collectThroughPairsUntil(db, targetField, sourceItems, count) {
128
165
  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 });
166
+ for await (const source of sourceItems) {
167
+ const pair = await resolveThroughPair(db, targetField, source);
168
+ if (!pair) {
169
+ continue;
170
+ }
171
+ pairs.push(pair);
135
172
  if (pairs.length >= count) {
136
173
  break;
137
174
  }
138
175
  }
139
176
  return pairs;
140
177
  }
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
178
  function createPlanRuntime(db, plan) {
148
179
  const source = plan.source;
149
180
  switch (source.kind) {
@@ -188,79 +219,23 @@ function createPlanRuntime(db, plan) {
188
219
  }
189
220
  };
190
221
  }
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
222
  }
243
223
  }
244
224
  async function decorateItem(plan, rawItem, item) {
245
225
  let output = item;
246
- if (plan.source.kind === "via" && plan.sourceKey) {
247
- output = { ...output, [plan.sourceKey]: rawItem.link };
248
- }
249
226
  if (plan.expanders.length > 0) {
250
- output = await applyExpanders(output, plan.expanders);
227
+ output = await applyExpanders(output, plan.expanders, createWithContext());
251
228
  }
252
229
  return output;
253
230
  }
254
231
  async function decorateItems(plan, rawItems, items) {
255
232
  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
233
  if (plan.expanders.length > 0) {
263
- output = await applyExpandersToMany(output, plan.expanders);
234
+ output = await applyExpandersToMany(
235
+ output,
236
+ plan.expanders,
237
+ () => createWithContext()
238
+ );
264
239
  }
265
240
  return output;
266
241
  }
@@ -342,10 +317,201 @@ async function executePaginate(db, plan, opts) {
342
317
  continueCursor: result?.continueCursor ?? opts.cursor ?? ""
343
318
  };
344
319
  }
320
+ function createSingleQueryBuilder(executeRoot, nullable) {
321
+ return {
322
+ ...createQueryNode(executeRoot),
323
+ _throughSourceKind: nullable ? "nullableSingle" : "single"
324
+ };
325
+ }
326
+ function createManyQueryBuilder(executeRoot) {
327
+ return {
328
+ ...createQueryNode(executeRoot),
329
+ _throughSourceKind: "many"
330
+ };
331
+ }
332
+ async function decorateThroughItem(expanders, pair) {
333
+ let output = pair.target;
334
+ if (expanders.length > 0) {
335
+ output = await applyExpanders(
336
+ output,
337
+ expanders,
338
+ createWithContext(pair.source)
339
+ );
340
+ }
341
+ return output;
342
+ }
343
+ async function decorateThroughItems(expanders, pairs) {
344
+ let output = pairs.map(({ target }) => target);
345
+ if (expanders.length > 0) {
346
+ output = await applyExpandersToMany(
347
+ output,
348
+ expanders,
349
+ (_item, index) => createWithContext(pairs[index].source)
350
+ );
351
+ }
352
+ return output;
353
+ }
354
+ async function executeThroughCollectionMany(db, plan) {
355
+ const sourceItems = await executeMany(db, plan.sourcePlan);
356
+ const pairs = await collectThroughPairs(
357
+ db,
358
+ plan.targetField,
359
+ sourceItems
360
+ );
361
+ return await decorateThroughItems(plan.expanders, pairs);
362
+ }
363
+ async function executeThroughCollectionFirst(db, plan) {
364
+ const pair = (await collectThroughPairsUntil(
365
+ db,
366
+ plan.targetField,
367
+ iterateDecoratedSourceItems(db, plan.sourcePlan),
368
+ 1
369
+ ))[0];
370
+ if (!pair) {
371
+ throw new Error(`Could not find first ${plan.targetTable} through ${sourceDescription(plan.sourcePlan)}`);
372
+ }
373
+ return await decorateThroughItem(plan.expanders, pair);
374
+ }
375
+ async function executeThroughCollectionFirstOrNull(db, plan) {
376
+ const pair = (await collectThroughPairsUntil(
377
+ db,
378
+ plan.targetField,
379
+ iterateDecoratedSourceItems(db, plan.sourcePlan),
380
+ 1
381
+ ))[0];
382
+ return pair ? await decorateThroughItem(plan.expanders, pair) : null;
383
+ }
384
+ async function executeThroughCollectionUnique(db, plan) {
385
+ const pairs = await collectThroughPairsUntil(
386
+ db,
387
+ plan.targetField,
388
+ iterateDecoratedSourceItems(db, plan.sourcePlan),
389
+ 2
390
+ );
391
+ if (pairs.length > 1) {
392
+ throw new Error("unique() returned more than one result");
393
+ }
394
+ const pair = pairs[0];
395
+ if (!pair) {
396
+ throw new Error(`Could not find ${plan.targetTable} through ${sourceDescription(plan.sourcePlan)}`);
397
+ }
398
+ return await decorateThroughItem(plan.expanders, pair);
399
+ }
400
+ async function executeThroughCollectionUniqueOrNull(db, plan) {
401
+ const pairs = await collectThroughPairsUntil(
402
+ db,
403
+ plan.targetField,
404
+ iterateDecoratedSourceItems(db, plan.sourcePlan),
405
+ 2
406
+ );
407
+ if (pairs.length > 1) {
408
+ throw new Error("unique() returned more than one result");
409
+ }
410
+ return pairs[0] ? await decorateThroughItem(plan.expanders, pairs[0]) : null;
411
+ }
412
+ async function executeThroughManyNode(db, plan) {
413
+ const sourceItems = await plan.sourceNode._executeRoot();
414
+ const pairs = await collectThroughPairs(
415
+ db,
416
+ plan.targetField,
417
+ sourceItems
418
+ );
419
+ return await decorateThroughItems(plan.expanders, pairs);
420
+ }
421
+ async function executeThroughSingleNode(db, plan, nullable) {
422
+ const sourceItem = await plan.sourceNode._executeRoot();
423
+ if (sourceItem == null) {
424
+ if (nullable) {
425
+ return null;
426
+ }
427
+ throw new Error(`Could not find ${plan.targetTable} through source query`);
428
+ }
429
+ const pair = await resolveThroughPair(
430
+ db,
431
+ plan.targetField,
432
+ sourceItem
433
+ );
434
+ if (!pair) {
435
+ if (nullable) {
436
+ return null;
437
+ }
438
+ throw new Error(`Could not find ${plan.targetTable} through source query`);
439
+ }
440
+ return await decorateThroughItem(plan.expanders, pair);
441
+ }
442
+ function withThroughCollectionExpander(plan, expander) {
443
+ return {
444
+ ...plan,
445
+ expanders: [...plan.expanders, expander]
446
+ };
447
+ }
448
+ function withThroughNodeExpander(plan, expander) {
449
+ return {
450
+ ...plan,
451
+ expanders: [...plan.expanders, expander]
452
+ };
453
+ }
454
+ function createThroughCollectionFacade(db, plan) {
455
+ return {
456
+ with(withBuilder) {
457
+ return createThroughCollectionFacade(db, withThroughCollectionExpander(plan, withBuilder));
458
+ },
459
+ unique() {
460
+ return createSingleQueryBuilder(
461
+ async () => await executeThroughCollectionUnique(db, plan),
462
+ false
463
+ );
464
+ },
465
+ uniqueOrNull() {
466
+ return createSingleQueryBuilder(
467
+ async () => await executeThroughCollectionUniqueOrNull(db, plan),
468
+ true
469
+ );
470
+ },
471
+ first() {
472
+ return createSingleQueryBuilder(
473
+ async () => await executeThroughCollectionFirst(db, plan),
474
+ false
475
+ );
476
+ },
477
+ firstOrNull() {
478
+ return createSingleQueryBuilder(
479
+ async () => await executeThroughCollectionFirstOrNull(db, plan),
480
+ true
481
+ );
482
+ },
483
+ many() {
484
+ return createManyQueryBuilder(
485
+ async () => await executeThroughCollectionMany(db, plan)
486
+ );
487
+ }
488
+ };
489
+ }
490
+ function createThroughManyQueryBuilder(db, plan) {
491
+ return {
492
+ ...createQueryNode(async () => await executeThroughManyNode(db, plan)),
493
+ _throughSourceKind: "many",
494
+ with(withBuilder) {
495
+ return createThroughManyQueryBuilder(
496
+ db,
497
+ withThroughNodeExpander(plan, withBuilder)
498
+ );
499
+ }
500
+ };
501
+ }
502
+ function createThroughSingleQueryBuilder(db, plan, nullable) {
503
+ return {
504
+ ...createQueryNode(async () => await executeThroughSingleNode(db, plan, nullable)),
505
+ _throughSourceKind: nullable ? "nullableSingle" : "single",
506
+ with(withBuilder) {
507
+ return createThroughSingleQueryBuilder(db, withThroughNodeExpander(plan, withBuilder), nullable);
508
+ }
509
+ };
510
+ }
345
511
  function createExpandableSingleFromPlan(db, plan, nullable) {
346
512
  const execute = async () => nullable ? await executeFindOrNull(db, plan) : await executeFind(db, plan);
347
513
  return {
348
- ...createQueryNode(execute),
514
+ ...createSingleQueryBuilder(execute, nullable),
349
515
  with(withBuilder) {
350
516
  return createExpandableSingleFromPlan(
351
517
  db,
@@ -364,12 +530,14 @@ function createBatchFacade(db, plan) {
364
530
  );
365
531
  },
366
532
  many() {
367
- return createQueryNode(async () => await executeMany(db, plan));
533
+ return createManyQueryBuilder(async () => await executeMany(db, plan));
368
534
  }
369
535
  };
370
536
  }
371
537
  function createCollectionFacade(db, plan) {
372
538
  const facade = {
539
+ _plan: plan,
540
+ _table: plan.source.table,
373
541
  with(withBuilder) {
374
542
  return createCollectionFacade(
375
543
  db,
@@ -389,30 +557,33 @@ function createCollectionFacade(db, plan) {
389
557
  );
390
558
  },
391
559
  unique() {
392
- return createQueryNode(async () => await executeUnique(db, plan));
560
+ return createSingleQueryBuilder(async () => await executeUnique(db, plan), false);
393
561
  },
394
562
  uniqueOrNull() {
395
- return createQueryNode(async () => await executeUniqueOrNull(db, plan));
563
+ return createSingleQueryBuilder(
564
+ async () => await executeUniqueOrNull(db, plan),
565
+ true
566
+ );
396
567
  },
397
568
  first() {
398
- return createQueryNode(async () => await executeFirst(db, plan));
569
+ return createSingleQueryBuilder(async () => await executeFirst(db, plan), false);
399
570
  },
400
571
  firstOrNull() {
401
- return createQueryNode(async () => await executeFirstOrNull(db, plan));
572
+ return createSingleQueryBuilder(
573
+ async () => await executeFirstOrNull(db, plan),
574
+ true
575
+ );
402
576
  },
403
577
  take(count) {
404
- return executeTake(db, plan, count);
578
+ return createManyQueryBuilder(async () => await executeTake(db, plan, count));
405
579
  },
406
580
  paginate(opts) {
407
581
  return executePaginate(db, plan, opts);
408
582
  },
409
583
  many() {
410
- return createQueryNode(async () => await executeMany(db, plan));
584
+ return createManyQueryBuilder(async () => await executeMany(db, plan));
411
585
  }
412
586
  };
413
- if (plan.source.kind === "via") {
414
- facade.withSource = (key) => createCollectionFacade(db, withSourceKey(plan, key));
415
- }
416
587
  return facade;
417
588
  }
418
589
  function createIdPlan(table, id) {
@@ -438,15 +609,23 @@ function createBatchPlan(table, index, values) {
438
609
  values
439
610
  });
440
611
  }
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
- });
612
+ function isCollectionSource(value) {
613
+ return typeof value === "object" && value !== null && "_plan" in value && "_table" in value;
614
+ }
615
+ function isManySourceNode(value) {
616
+ return typeof value === "object" && value !== null && "_executeRoot" in value && value._throughSourceKind === "many";
617
+ }
618
+ function isSingleSourceNode(value) {
619
+ return typeof value === "object" && value !== null && "_executeRoot" in value && (value._throughSourceKind === "single" || value._throughSourceKind === "nullableSingle");
620
+ }
621
+ function normalizeIndexSelectorArgs(args) {
622
+ if (args.length === 0) {
623
+ return void 0;
624
+ }
625
+ if (args.length === 1) {
626
+ return args[0];
627
+ }
628
+ return args;
450
629
  }
451
630
  function createTableNamespace(db, table) {
452
631
  const rootFacade = createCollectionFacade(db, createQueryPlan(table));
@@ -461,8 +640,36 @@ function createTableNamespace(db, table) {
461
640
  in(ids) {
462
641
  return createBatchFacade(db, createBatchPlan(table, "by_id", ids));
463
642
  },
464
- via(joinTable, targetField) {
465
- return createViaNamespace(db, table, joinTable, targetField);
643
+ through(sourceQuery, targetField) {
644
+ if (isCollectionSource(sourceQuery)) {
645
+ return createThroughCollectionFacade(db, {
646
+ targetTable: table,
647
+ targetField,
648
+ sourcePlan: sourceQuery._plan,
649
+ expanders: []
650
+ });
651
+ }
652
+ if (isManySourceNode(sourceQuery)) {
653
+ return createThroughManyQueryBuilder(db, {
654
+ targetTable: table,
655
+ targetField,
656
+ sourceNode: sourceQuery,
657
+ expanders: []
658
+ });
659
+ }
660
+ if (isSingleSourceNode(sourceQuery)) {
661
+ return createThroughSingleQueryBuilder(
662
+ db,
663
+ {
664
+ targetTable: table,
665
+ targetField,
666
+ sourceNode: sourceQuery,
667
+ expanders: []
668
+ },
669
+ sourceQuery._throughSourceKind === "nullableSingle"
670
+ );
671
+ }
672
+ throw new Error("through() requires a query facade or lazy query node");
466
673
  }
467
674
  };
468
675
  return new Proxy(target, {
@@ -473,9 +680,13 @@ function createTableNamespace(db, table) {
473
680
  if (RESERVED_PROMISE_KEYS.has(prop) || prop === "all") {
474
681
  return void 0;
475
682
  }
476
- const indexMethod = ((value) => createCollectionFacade(
683
+ const indexMethod = ((...args) => createCollectionFacade(
477
684
  db,
478
- createQueryPlan(table, prop, value)
685
+ createQueryPlan(
686
+ table,
687
+ prop,
688
+ normalizeIndexSelectorArgs(args)
689
+ )
479
690
  ));
480
691
  indexMethod.in = (values) => createBatchFacade(
481
692
  db,
@@ -485,28 +696,6 @@ function createTableNamespace(db, table) {
485
696
  }
486
697
  });
487
698
  }
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
699
  function createQueryFacade(db) {
511
700
  return new Proxy(
512
701
  {},
@@ -520,9 +709,6 @@ function createQueryFacade(db) {
520
709
  }
521
710
  );
522
711
  }
523
- function compute(load) {
524
- return createQueryNode(async () => await load());
525
- }
526
712
  async function queryUniqueByIndex(db, table, index, value) {
527
713
  if (index === "by_id") {
528
714
  return await db.query(table).withIndex("by_id", (q) => q.eq("_id", value)).unique();
@@ -533,6 +719,5 @@ async function queryUniqueByIndex(db, table, index, value) {
533
719
  ).unique();
534
720
  }
535
721
  export {
536
- compute,
537
722
  createQueryFacade
538
723
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davidtkramer/convex-relations",
3
- "version": "0.1.0",
3
+ "version": "0.4.0",
4
4
  "description": "Typed query facade helpers for Convex backends",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -42,6 +42,7 @@
42
42
  "convex-test": "^0.0.41",
43
43
  "tsup": "^8.5.0",
44
44
  "typescript": "^5.9.2",
45
+ "typical-data": "^0.5.0",
45
46
  "vitest": "^4.0.15"
46
47
  },
47
48
  "packageManager": "pnpm@9.15.9"