@davidtkramer/convex-relations 0.1.0 → 0.2.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/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,40 @@ 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
+ - [Type Notes](#type-notes)
130
+ - [License](#license)
131
+
132
+ ## Installation
133
+
134
+ ```bash
135
+ npm install @davidtkramer/convex-relations
136
+ ```
137
+
138
+ ```bash
139
+ pnpm add @davidtkramer/convex-relations
140
+ ```
141
+
142
+ ```bash
143
+ bun add @davidtkramer/convex-relations
144
+ ```
145
+
146
+ ```bash
147
+ yarn add @davidtkramer/convex-relations
148
+ ```
154
149
 
155
150
  ## Quick Start
156
151
 
@@ -220,13 +215,47 @@ comments: defineTable({
220
215
  const author = await ctx.q.authors.bySlug("ada-lovelace").unique();
221
216
  const comments = await ctx.q.comments.byPostId(postId).many();
222
217
  const approvedComments = await ctx.q.comments
223
- .byPostIdAndStatus({ postId, status: "approved" })
218
+ .byPostIdAndStatus(postId, "approved")
224
219
  .many();
225
220
  ```
226
221
 
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.
222
+ Single-field indexes accept a scalar. Compound indexes accept positional
223
+ arguments in index order. Zero-argument calls give you the indexed range so you
224
+ can filter, sort, paginate, or take a subset.
225
+
226
+ ### `with(...)` builds nested result shapes
227
+
228
+ `with(...)` lets you attach additional fields to every document in a query. The
229
+ callback receives the current document and returns an object whose values are
230
+ other query nodes or `compute(...)` calls.
231
+
232
+ ```ts
233
+ const post = await ctx.q.posts
234
+ .bySlug("hello-world")
235
+ .with((post) => ({
236
+ author: ctx.q.authors.find(post.authorId),
237
+ comments: ctx.q.comments.byPostId(post._id).take(10),
238
+ }))
239
+ .unique();
240
+ ```
241
+
242
+ That returns a single object shaped like:
243
+
244
+ ```ts
245
+ {
246
+ ...post,
247
+ author,
248
+ comments,
249
+ }
250
+ ```
251
+
252
+ This is the core idea behind the library: compose the data you want as a tree,
253
+ and `convex-relations` resolves and assembles that tree for you.
254
+
255
+ Within a single `with(...)`, sibling fields are resolved in parallel. If you
256
+ attach both `author` and `comments`, those branches start loading at the same
257
+ time. Nested `with(...)` calls preserve that behavior recursively, so each level
258
+ of the result tree parallelizes across its sibling fields.
230
259
 
231
260
  ## API
232
261
 
@@ -291,16 +320,16 @@ Single-field indexes accept a scalar:
291
320
  const author = await q.authors.bySlug("ada-lovelace").unique();
292
321
  ```
293
322
 
294
- Compound indexes accept an object containing a valid prefix:
323
+ Compound indexes accept leading positional arguments:
295
324
 
296
325
  ```ts
297
326
  const comments = await q.comments
298
- .byPostIdAndCreatedAt({ postId })
327
+ .byPostIdAndStatus(postId)
299
328
  .order("desc")
300
329
  .take(20);
301
330
 
302
331
  const exactOrPrefix = await q.comments
303
- .byPostIdAndCreatedAt({ postId, createdAt: 1700000000000 })
332
+ .byPostIdAndStatus(postId, "approved")
304
333
  .many();
305
334
  ```
306
335
 
@@ -309,8 +338,8 @@ const exactOrPrefix = await q.comments
309
338
  You can also pass Convex's index selector callback:
310
339
 
311
340
  ```ts
312
- const recentComments = await q.comments
313
- .byPostIdAndCreatedAt((q) => q.eq("postId", postId).gt("createdAt", cutoff))
341
+ const approvedComments = await q.comments
342
+ .byPostIdAndStatus((q) => q.eq("postId", postId).eq("status", "approved"))
314
343
  .many();
315
344
  ```
316
345
 
@@ -362,25 +391,27 @@ const post = await q.posts
362
391
 
363
392
  Each `with(...)` stage sees fields added by earlier stages.
364
393
 
365
- ## Join Table Traversal with `via(...)`
394
+ ## Reference Traversal with `through(...)`
366
395
 
367
- Use `via(joinTable, targetField)` for many-to-many relationships.
396
+ Use `through(sourceQuery, foreignKeyField)` when another query already produces
397
+ rows that point at the table you want.
368
398
 
369
399
  Given `postCategories { postId, categoryId }`, you can fetch categories for a post:
370
400
 
371
401
  ```ts
372
402
  const categories = await q.categories
373
- .via("postCategories", "categoryId")
374
- .byPostId(postId)
403
+ .through(q.postCategories.byPostId(postId), "categoryId")
375
404
  .many();
376
405
  ```
377
406
 
378
- You can also attach the join row with `withSource(...)`:
407
+ This is essentially syntactic sugar for "run the source query, extract ids from
408
+ that field, then load the target rows for you."
409
+
410
+ You can also attach the source row with `withSource(...)`:
379
411
 
380
412
  ```ts
381
413
  const categories = await q.categories
382
- .via("postCategories", "categoryId")
383
- .byPostId(postId)
414
+ .through(q.postCategories.byPostId(postId).order("desc"), "categoryId")
384
415
  .withSource("link")
385
416
  .many();
386
417
 
@@ -388,7 +419,38 @@ categories[0]?.link.postId;
388
419
  categories[0]?.link.categoryId;
389
420
  ```
390
421
 
391
- This is useful when the join table stores metadata like ordering, role, or timestamps.
422
+ This is useful when the source table stores metadata like ordering, role, or
423
+ timestamps.
424
+
425
+ `through(...)` is not limited to join tables. Any compatible source query works:
426
+
427
+ ```ts
428
+ const author = await q.authors
429
+ .through(q.posts.bySlug("hello-world"), "authorId")
430
+ .withSource("post")
431
+ .unique();
432
+
433
+ author.post.slug;
434
+ ```
435
+
436
+ Source-query shaping lives inside the `through(...)` argument:
437
+
438
+ ```ts
439
+ const tags = await q.tags
440
+ .through(
441
+ q.postTags
442
+ .byPostId(postId)
443
+ .filter((query) => query.eq(query.field("kind"), "primary"))
444
+ .order("desc")
445
+ .take(10),
446
+ "tagId",
447
+ )
448
+ .withSource("link")
449
+ .many();
450
+ ```
451
+
452
+ After `through(...)`, you can keep shaping the target result with `with(...)`
453
+ and `withSource(...)`, then choose a terminal like `many()` or `first()`.
392
454
 
393
455
  ## Terminals
394
456
 
@@ -447,7 +509,6 @@ const page = await q.posts.byAuthorId(authorId).paginate({
447
509
  - `unique()` also throws if there are multiple matches
448
510
  - `first()` throws if there is no match
449
511
  - `findOrNull()`, `uniqueOrNull()`, and `firstOrNull()` return `null` instead
450
- - `via(...).unique()` normalizes its duplicate error to include the target table and join index
451
512
 
452
513
  ## Performance Characteristics
453
514
 
@@ -461,8 +522,7 @@ const post = await q.posts.find(postId).with((post) => ({
461
522
  comments: q.comments.byPostId(post._id).take(10),
462
523
  categoryCount: compute(async () => {
463
524
  const categories = await q.categories
464
- .via("postCategories", "categoryId")
465
- .byPostId(post._id)
525
+ .through(q.postCategories.byPostId(post._id), "categoryId")
466
526
  .many();
467
527
  return categories.length;
468
528
  }),
@@ -489,7 +549,9 @@ q.posts
489
549
 
490
550
  The second stage waits for the first stage, because it depends on fields added earlier.
491
551
 
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.
552
+ `through(...)` currently resolves target documents by fetching the source rows
553
+ first, then loading each target document individually. This is correct and
554
+ predictable, but it is not a single batched join at the database level.
493
555
 
494
556
  ### Practical guidance
495
557
 
@@ -507,8 +569,7 @@ This:
507
569
 
508
570
  ```ts
509
571
  const categories = await q.categories
510
- .via("postCategories", "categoryId")
511
- .byPostId(postId)
572
+ .through(q.postCategories.byPostId(postId), "categoryId")
512
573
  .many();
513
574
  ```
514
575
 
@@ -532,7 +593,7 @@ but also composes naturally with `with(...)`, `take(...)`, `firstOrNull()`, and
532
593
  - Table names, `_id` types, index names, and compound index prefixes are inferred
533
594
  - Invalid table names and invalid index names are rejected at compile time
534
595
  - Scalar shorthand is only allowed for single-field indexes
535
- - Compound indexes require a valid prefix object
596
+ - Compound indexes use leading positional arguments
536
597
 
537
598
  ## License
538
599
 
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,28 +8,41 @@ 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
  };
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
+ };
33
46
  type WithSpec = Record<string, QueryNode<any>>;
34
47
  type WithBuilder<ParentItem, Spec extends WithSpec | undefined = WithSpec | undefined> = (parent: ParentItem) => Spec;
35
48
  type AnyWithBuilder<ParentItem> = WithBuilder<ParentItem, WithSpec | undefined>;
@@ -49,81 +62,92 @@ type PaginatedResult<Item> = {
49
62
  isDone: boolean;
50
63
  continueCursor: string;
51
64
  };
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> & {
65
+ type QueryFilter<TableInfo extends GenericTableInfo> = (q: FilterBuilder<TableInfo>) => ExpressionOrValue<boolean>;
66
+ type QueryModifier = (query: any) => any;
67
+ 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;
68
+ type SingleNodeKind<Nullable extends boolean> = Nullable extends true ? 'nullableSingle' : 'single';
69
+ type SingleQueryBuilder<Item, Nullable extends boolean> = QueryNode<Nullable extends true ? Item | null : Item> & ThroughNodeHandle<Item, SingleNodeKind<Nullable>>;
70
+ type ExpandableSingleQueryBuilder<Item, Nullable extends boolean> = SingleQueryBuilder<Item, Nullable> & {
55
71
  with<Builder extends AnyWithBuilder<Item>>(withBuilder: Builder): ExpandableSingleQueryBuilder<ExpandWith<Item, Builder>, Nullable>;
56
72
  };
57
- type SingleQueryBuilder<Item, Nullable extends boolean> = QueryNode<Nullable extends true ? Item | null : Item>;
58
73
  type UniqueQueryBuilder<Item> = SingleQueryBuilder<Item, false>;
59
74
  type UniqueOrNullQueryBuilder<Item> = SingleQueryBuilder<Item, true>;
60
75
  type FirstQueryBuilder<Item> = SingleQueryBuilder<Item, false>;
61
76
  type FirstOrNullQueryBuilder<Item> = SingleQueryBuilder<Item, true>;
62
77
  type FindQueryBuilder<Item> = ExpandableSingleQueryBuilder<Item, false>;
63
78
  type FindOrNullQueryBuilder<Item> = ExpandableSingleQueryBuilder<Item, true>;
64
- type ManyQueryBuilder<Item> = QueryNode<Item[]>;
65
- type BatchQueryBuilder<Item> = QueryNode<Item[]>;
66
- type ManyViaQueryBuilder<Item> = QueryNode<Item[]>;
79
+ type ManyQueryBuilder<Item> = QueryNode<Item[]> & ThroughNodeHandle<Item, 'many'>;
80
+ type BatchQueryBuilder<Item> = ManyQueryBuilder<Item>;
81
+ type ThroughSourceNodeKind = 'many' | 'single' | 'nullableSingle';
82
+ type ThroughNodeHandle<SourceItem, Kind extends ThroughSourceNodeKind> = {
83
+ readonly _throughSourceKind: Kind;
84
+ readonly _throughSourceType?: SourceItem;
85
+ };
86
+ type AnyManySourceNode<SourceItem> = QueryNode<SourceItem[]> & ThroughNodeHandle<SourceItem, 'many'>;
87
+ type AnySingleSourceNode<SourceItem, Nullable extends boolean> = QueryNode<Nullable extends true ? SourceItem | null : SourceItem> & ThroughNodeHandle<SourceItem, SingleNodeKind<Nullable>>;
88
+ type ThroughSourceField<DataModel extends GenericDataModel, TargetTable extends AppTable<DataModel>, SourceItem> = {
89
+ [Field in Extract<keyof SourceItem, string>]: IdTargetTable<DataModel, SourceItem[Field]> extends TargetTable ? Field : never;
90
+ }[Extract<keyof SourceItem, string>];
91
+ type ManyThroughQueryBuilder<Item, SourceItem = unknown> = QueryNode<Item[]> & ThroughNodeHandle<SourceItem, 'many'> & {
92
+ with<Builder extends AnyWithBuilder<Item>>(withBuilder: Builder): ManyThroughQueryBuilder<ExpandWith<Item, Builder>, SourceItem>;
93
+ withSource<const SourceKey extends string>(key: SourceKey): ManyThroughQueryBuilder<AttachSource<Item, SourceItem, SourceKey>, SourceItem>;
94
+ };
95
+ type SingleThroughQueryBuilder<Item, SourceItem, Nullable extends boolean> = QueryNode<Nullable extends true ? Item | null : Item> & ThroughNodeHandle<SourceItem, Nullable extends true ? 'nullableSingle' : 'single'> & {
96
+ with<Builder extends AnyWithBuilder<Item>>(withBuilder: Builder): SingleThroughQueryBuilder<ExpandWith<Item, Builder>, SourceItem, Nullable>;
97
+ withSource<const SourceKey extends string>(key: SourceKey): SingleThroughQueryBuilder<AttachSource<Item, SourceItem, SourceKey>, SourceItem, Nullable>;
98
+ };
67
99
  type TableQueryFacade<DataModel extends GenericDataModel, Table extends AppTable<DataModel>, Item = AppDoc<DataModel, Table>> = {
68
100
  with<Builder extends AnyWithBuilder<Item>>(withBuilder: Builder): TableQueryFacade<DataModel, Table, ExpandWith<Item, Builder>>;
69
101
  order(direction: 'asc' | 'desc'): TableQueryFacade<DataModel, Table, Item>;
70
- filter(filterer: QueryFilter): TableQueryFacade<DataModel, Table, Item>;
102
+ filter(filterer: QueryFilter<TableInfo<DataModel, Table>>): TableQueryFacade<DataModel, Table, Item>;
71
103
  unique(): UniqueQueryBuilder<Item>;
72
104
  uniqueOrNull(): UniqueOrNullQueryBuilder<Item>;
73
105
  first(): FirstQueryBuilder<Item>;
74
106
  firstOrNull(): FirstOrNullQueryBuilder<Item>;
75
- take(count: number): Promise<Item[]>;
107
+ take(count: number): ManyQueryBuilder<Item>;
76
108
  paginate(opts: PaginationOptions): Promise<PaginatedResult<Item>>;
77
109
  many(): ManyQueryBuilder<Item>;
78
- };
110
+ } & QueryPlanHandle<DataModel, Table>;
79
111
  type TableRangeQueryFacade<DataModel extends GenericDataModel, Table extends AppTable<DataModel>, Item = AppDoc<DataModel, Table>> = {
80
112
  with<Builder extends AnyWithBuilder<Item>>(withBuilder: Builder): TableRangeQueryFacade<DataModel, Table, ExpandWith<Item, Builder>>;
81
113
  order(direction: 'asc' | 'desc'): TableRangeQueryFacade<DataModel, Table, Item>;
82
- filter(filterer: QueryFilter): TableRangeQueryFacade<DataModel, Table, Item>;
114
+ filter(filterer: QueryFilter<TableInfo<DataModel, Table>>): TableRangeQueryFacade<DataModel, Table, Item>;
83
115
  unique(): UniqueQueryBuilder<Item>;
84
116
  uniqueOrNull(): UniqueOrNullQueryBuilder<Item>;
85
117
  first(): FirstQueryBuilder<Item>;
86
118
  firstOrNull(): FirstOrNullQueryBuilder<Item>;
87
- take(count: number): Promise<Item[]>;
119
+ take(count: number): ManyQueryBuilder<Item>;
88
120
  paginate(opts: PaginationOptions): Promise<PaginatedResult<Item>>;
89
121
  many(): ManyQueryBuilder<Item>;
90
- };
122
+ } & QueryPlanHandle<DataModel, Table>;
91
123
  type TableBatchQueryFacade<DataModel extends GenericDataModel, Table extends AppTable<DataModel>, Item = AppDoc<DataModel, Table>> = {
92
124
  with<Builder extends AnyWithBuilder<Item>>(withBuilder: Builder): TableBatchQueryFacade<DataModel, Table, ExpandWith<Item, Builder>>;
93
125
  many(): BatchQueryBuilder<Item>;
94
126
  };
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>>;
127
+ type ThroughQueryFacade<DataModel extends GenericDataModel, TargetTable extends AppTable<DataModel>, SourceItem, Item = AppDoc<DataModel, TargetTable>> = {
128
+ with<Builder extends AnyWithBuilder<Item>>(withBuilder: Builder): ThroughQueryFacade<DataModel, TargetTable, SourceItem, ExpandWith<Item, Builder>>;
129
+ withSource<const SourceKey extends string>(key: SourceKey): ThroughQueryFacade<DataModel, TargetTable, SourceItem, AttachSource<Item, SourceItem, SourceKey>>;
100
130
  unique(): UniqueQueryBuilder<Item>;
101
131
  uniqueOrNull(): UniqueOrNullQueryBuilder<Item>;
102
132
  first(): FirstQueryBuilder<Item>;
103
133
  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
- };
134
+ many(): ManyQueryBuilder<Item>;
117
135
  };
136
+ type ThroughCollectionSource<DataModel extends GenericDataModel, SourceTable extends AppTable<DataModel>, SourceItem = AppDoc<DataModel, SourceTable>> = TableQueryFacade<DataModel, SourceTable, SourceItem> | TableRangeQueryFacade<DataModel, SourceTable, SourceItem>;
118
137
  type TableNamespace<DataModel extends GenericDataModel, Table extends AppTable<DataModel>> = {
119
138
  find<const Id extends GenericId<Table>>(id: Id): FindQueryBuilder<AppDoc<DataModel, Table>>;
120
139
  findOrNull<const Id extends GenericId<Table>>(id: Id): FindOrNullQueryBuilder<AppDoc<DataModel, Table>>;
121
140
  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>;
141
+ through: {
142
+ <const SourceTable extends AppTable<DataModel>, SourceItem, const TargetField extends ThroughSourceField<DataModel, Table, SourceItem>>(sourceQuery: ThroughCollectionSource<DataModel, SourceTable, SourceItem>, targetField: TargetField): ThroughQueryFacade<DataModel, Table, SourceItem>;
143
+ <SourceItem, const TargetField extends ThroughSourceField<DataModel, Table, SourceItem>>(sourceQuery: AnyManySourceNode<SourceItem>, targetField: TargetField): ManyThroughQueryBuilder<AppDoc<DataModel, Table>, SourceItem>;
144
+ <SourceItem, const TargetField extends ThroughSourceField<DataModel, Table, SourceItem>>(sourceQuery: AnySingleSourceNode<SourceItem, false>, targetField: TargetField): SingleThroughQueryBuilder<AppDoc<DataModel, Table>, SourceItem, false>;
145
+ <SourceItem, const TargetField extends ThroughSourceField<DataModel, Table, SourceItem>>(sourceQuery: AnySingleSourceNode<SourceItem, true>, targetField: TargetField): SingleThroughQueryBuilder<AppDoc<DataModel, Table>, SourceItem, true>;
146
+ };
123
147
  } & TableRangeQueryFacade<DataModel, Table> & {
124
148
  [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>;
149
+ (selector: IndexName extends UserIndex<DataModel, Table> ? IndexSelector<DataModel, Table, IndexName> : never): TableQueryFacade<DataModel, Table>;
150
+ <const Args extends TableIndexInvocationArgs<DataModel, Table, IndexName>>(...args: Args): TableQueryFacade<DataModel, Table>;
127
151
  (): TableRangeQueryFacade<DataModel, Table>;
128
152
  in<const Value extends TableIndexValueArg<DataModel, Table, IndexName>>(values: StrictTableIndexValueArg<DataModel, Table, IndexName, Value>[]): TableBatchQueryFacade<DataModel, Table>;
129
153
  };
@@ -131,6 +155,26 @@ type TableNamespace<DataModel extends GenericDataModel, Table extends AppTable<D
131
155
  type QueryFacade<DataModel extends GenericDataModel> = {
132
156
  [Table in AppTable<DataModel>]: TableNamespace<DataModel, Table>;
133
157
  };
158
+ type QuerySourcePlan = {
159
+ kind: 'id';
160
+ table: string;
161
+ id: GenericId<any>;
162
+ } | {
163
+ kind: 'query';
164
+ table: string;
165
+ index?: string;
166
+ selector?: unknown;
167
+ } | {
168
+ kind: 'batch';
169
+ table: string;
170
+ index: string;
171
+ values: unknown[];
172
+ };
173
+ type QueryPlan = {
174
+ source: QuerySourcePlan;
175
+ modifiers: QueryModifier[];
176
+ expanders: AnyWithBuilder<any>[];
177
+ };
134
178
  declare function createQueryFacade<DataModel extends GenericDataModel>(db: GenericDatabaseReader<DataModel>): QueryFacade<DataModel>;
135
179
  declare function compute<Output = unknown>(load: () => Promise<Output> | Output): QueryNode<Output>;
136
180
 
package/dist/index.js CHANGED
@@ -52,13 +52,11 @@ function withExpander(plan, expander) {
52
52
  expanders: [...plan.expanders, expander]
53
53
  };
54
54
  }
55
- function withSourceKey(plan, sourceKey) {
56
- return {
57
- ...plan,
58
- sourceKey
59
- };
60
- }
61
55
  function normalizeIndexValues(index, value) {
56
+ if (Array.isArray(value)) {
57
+ const fieldNames = inferFieldNamesFromIndex(index);
58
+ return Object.fromEntries(fieldNames.map((field, index2) => [field, value[index2]]));
59
+ }
62
60
  if (isPlainObject(value)) {
63
61
  return value;
64
62
  }
@@ -77,8 +75,14 @@ function applyIndexValues(query, values) {
77
75
  return current;
78
76
  }
79
77
  function inferFieldNameFromIndex(index) {
78
+ return inferFieldNamesFromIndex(index)[0];
79
+ }
80
+ function inferFieldNamesFromIndex(index) {
81
+ if (index === "by_id") {
82
+ return ["_id"];
83
+ }
80
84
  if (index.startsWith("by") && index.length > 2) {
81
- return `${index[2].toLowerCase()}${index.slice(3)}`;
85
+ return index.slice(2).split("And").map((part) => `${part[0].toLowerCase()}${part.slice(1)}`);
82
86
  }
83
87
  throw new Error(`Cannot infer field name from index ${index}`);
84
88
  }
@@ -101,49 +105,61 @@ function createIndexedQuery(db, table, index, selector) {
101
105
  (q) => applyIndexValues(q, normalizeIndexValues(index, selector))
102
106
  );
103
107
  }
104
- function createViaQuery(db, joinTable, index, selector) {
105
- const baseQuery = db.query(joinTable);
106
- if (selector === void 0) {
107
- return baseQuery.withIndex(index);
108
+ function sourceDescription(plan) {
109
+ switch (plan.source.kind) {
110
+ case "id":
111
+ return `${plan.source.table} with id ${plan.source.id}`;
112
+ case "batch":
113
+ return `${plan.source.table} via ${plan.source.index}`;
114
+ case "query":
115
+ return plan.source.index ? `${plan.source.table} with index ${plan.source.index}` : plan.source.table;
108
116
  }
109
- if (typeof selector === "function") {
110
- return baseQuery.withIndex(index, selector);
117
+ }
118
+ async function resolveThroughPair(db, targetField, source) {
119
+ const id = source[targetField];
120
+ if (!id) {
121
+ return null;
111
122
  }
112
- return baseQuery.withIndex(
113
- index,
114
- (q) => applyIndexValues(q, normalizeIndexValues(index, selector))
115
- );
123
+ const target = await db.get(id);
124
+ return target ? { source, target } : null;
116
125
  }
117
- async function collectViaPairs(db, targetField, links) {
126
+ async function collectThroughPairs(db, targetField, sourceItems) {
118
127
  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
- })
128
+ sourceItems.map(async (source) => await resolveThroughPair(db, targetField, source))
124
129
  );
125
130
  return pairs.filter((pair) => pair !== null);
126
131
  }
127
- async function collectViaPairsUntil(db, targetField, query, count) {
132
+ async function* iterateDecoratedSourceItems(db, plan) {
133
+ if (plan.source.kind !== "query") {
134
+ for (const item of await executeMany(db, plan)) {
135
+ yield item;
136
+ }
137
+ return;
138
+ }
139
+ const runtime = createPlanRuntime(db, plan);
140
+ const source = plan.source;
141
+ const query = buildQuery(
142
+ () => createIndexedQuery(db, source.table, source.index, source.selector),
143
+ plan.modifiers
144
+ );
145
+ for await (const rawItem of query) {
146
+ yield await decorateItem(plan, rawItem, await runtime.mapOne(rawItem));
147
+ }
148
+ }
149
+ async function collectThroughPairsUntil(db, targetField, sourceItems, count) {
128
150
  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 });
151
+ for await (const source of sourceItems) {
152
+ const pair = await resolveThroughPair(db, targetField, source);
153
+ if (!pair) {
154
+ continue;
155
+ }
156
+ pairs.push(pair);
135
157
  if (pairs.length >= count) {
136
158
  break;
137
159
  }
138
160
  }
139
161
  return pairs;
140
162
  }
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
163
  function createPlanRuntime(db, plan) {
148
164
  const source = plan.source;
149
165
  switch (source.kind) {
@@ -188,64 +204,10 @@ function createPlanRuntime(db, plan) {
188
204
  }
189
205
  };
190
206
  }
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
207
  }
243
208
  }
244
209
  async function decorateItem(plan, rawItem, item) {
245
210
  let output = item;
246
- if (plan.source.kind === "via" && plan.sourceKey) {
247
- output = { ...output, [plan.sourceKey]: rawItem.link };
248
- }
249
211
  if (plan.expanders.length > 0) {
250
212
  output = await applyExpanders(output, plan.expanders);
251
213
  }
@@ -253,12 +215,6 @@ async function decorateItem(plan, rawItem, item) {
253
215
  }
254
216
  async function decorateItems(plan, rawItems, items) {
255
217
  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
218
  if (plan.expanders.length > 0) {
263
219
  output = await applyExpandersToMany(output, plan.expanders);
264
220
  }
@@ -342,10 +298,219 @@ async function executePaginate(db, plan, opts) {
342
298
  continueCursor: result?.continueCursor ?? opts.cursor ?? ""
343
299
  };
344
300
  }
301
+ function createSingleQueryBuilder(executeRoot, nullable) {
302
+ return {
303
+ ...createQueryNode(executeRoot),
304
+ _throughSourceKind: nullable ? "nullableSingle" : "single"
305
+ };
306
+ }
307
+ function createManyQueryBuilder(executeRoot) {
308
+ return {
309
+ ...createQueryNode(executeRoot),
310
+ _throughSourceKind: "many"
311
+ };
312
+ }
313
+ async function decorateThroughItem(expanders, sourceKey, pair) {
314
+ let output = pair.target;
315
+ if (sourceKey) {
316
+ output = { ...output, [sourceKey]: pair.source };
317
+ }
318
+ if (expanders.length > 0) {
319
+ output = await applyExpanders(output, expanders);
320
+ }
321
+ return output;
322
+ }
323
+ async function decorateThroughItems(expanders, sourceKey, pairs) {
324
+ let output = pairs.map(
325
+ ({ source, target }) => sourceKey ? { ...target, [sourceKey]: source } : target
326
+ );
327
+ if (expanders.length > 0) {
328
+ output = await applyExpandersToMany(output, expanders);
329
+ }
330
+ return output;
331
+ }
332
+ async function executeThroughCollectionMany(db, plan) {
333
+ const sourceItems = await executeMany(db, plan.sourcePlan);
334
+ const pairs = await collectThroughPairs(
335
+ db,
336
+ plan.targetField,
337
+ sourceItems
338
+ );
339
+ return await decorateThroughItems(plan.expanders, plan.sourceKey, pairs);
340
+ }
341
+ async function executeThroughCollectionFirst(db, plan) {
342
+ const pair = (await collectThroughPairsUntil(
343
+ db,
344
+ plan.targetField,
345
+ iterateDecoratedSourceItems(db, plan.sourcePlan),
346
+ 1
347
+ ))[0];
348
+ if (!pair) {
349
+ throw new Error(`Could not find first ${plan.targetTable} through ${sourceDescription(plan.sourcePlan)}`);
350
+ }
351
+ return await decorateThroughItem(plan.expanders, plan.sourceKey, pair);
352
+ }
353
+ async function executeThroughCollectionFirstOrNull(db, plan) {
354
+ const pair = (await collectThroughPairsUntil(
355
+ db,
356
+ plan.targetField,
357
+ iterateDecoratedSourceItems(db, plan.sourcePlan),
358
+ 1
359
+ ))[0];
360
+ return pair ? await decorateThroughItem(plan.expanders, plan.sourceKey, pair) : null;
361
+ }
362
+ async function executeThroughCollectionUnique(db, plan) {
363
+ const pairs = await collectThroughPairsUntil(
364
+ db,
365
+ plan.targetField,
366
+ iterateDecoratedSourceItems(db, plan.sourcePlan),
367
+ 2
368
+ );
369
+ if (pairs.length > 1) {
370
+ throw new Error("unique() returned more than one result");
371
+ }
372
+ const pair = pairs[0];
373
+ if (!pair) {
374
+ throw new Error(`Could not find ${plan.targetTable} through ${sourceDescription(plan.sourcePlan)}`);
375
+ }
376
+ return await decorateThroughItem(plan.expanders, plan.sourceKey, pair);
377
+ }
378
+ async function executeThroughCollectionUniqueOrNull(db, plan) {
379
+ const pairs = await collectThroughPairsUntil(
380
+ db,
381
+ plan.targetField,
382
+ iterateDecoratedSourceItems(db, plan.sourcePlan),
383
+ 2
384
+ );
385
+ if (pairs.length > 1) {
386
+ throw new Error("unique() returned more than one result");
387
+ }
388
+ return pairs[0] ? await decorateThroughItem(plan.expanders, plan.sourceKey, pairs[0]) : null;
389
+ }
390
+ async function executeThroughManyNode(db, plan) {
391
+ const sourceItems = await plan.sourceNode._executeRoot();
392
+ const pairs = await collectThroughPairs(
393
+ db,
394
+ plan.targetField,
395
+ sourceItems
396
+ );
397
+ return await decorateThroughItems(plan.expanders, plan.sourceKey, pairs);
398
+ }
399
+ async function executeThroughSingleNode(db, plan, nullable) {
400
+ const sourceItem = await plan.sourceNode._executeRoot();
401
+ if (sourceItem == null) {
402
+ if (nullable) {
403
+ return null;
404
+ }
405
+ throw new Error(`Could not find ${plan.targetTable} through source query`);
406
+ }
407
+ const pair = await resolveThroughPair(
408
+ db,
409
+ plan.targetField,
410
+ sourceItem
411
+ );
412
+ if (!pair) {
413
+ if (nullable) {
414
+ return null;
415
+ }
416
+ throw new Error(`Could not find ${plan.targetTable} through source query`);
417
+ }
418
+ return await decorateThroughItem(plan.expanders, plan.sourceKey, pair);
419
+ }
420
+ function withThroughCollectionExpander(plan, expander) {
421
+ return {
422
+ ...plan,
423
+ expanders: [...plan.expanders, expander]
424
+ };
425
+ }
426
+ function withThroughCollectionSourceKey(plan, sourceKey) {
427
+ return {
428
+ ...plan,
429
+ sourceKey
430
+ };
431
+ }
432
+ function withThroughNodeExpander(plan, expander) {
433
+ return {
434
+ ...plan,
435
+ expanders: [...plan.expanders, expander]
436
+ };
437
+ }
438
+ function withThroughNodeSourceKey(plan, sourceKey) {
439
+ return {
440
+ ...plan,
441
+ sourceKey
442
+ };
443
+ }
444
+ function createThroughCollectionFacade(db, plan) {
445
+ return {
446
+ with(withBuilder) {
447
+ return createThroughCollectionFacade(db, withThroughCollectionExpander(plan, withBuilder));
448
+ },
449
+ withSource(key) {
450
+ return createThroughCollectionFacade(db, withThroughCollectionSourceKey(plan, key));
451
+ },
452
+ unique() {
453
+ return createSingleQueryBuilder(
454
+ async () => await executeThroughCollectionUnique(db, plan),
455
+ false
456
+ );
457
+ },
458
+ uniqueOrNull() {
459
+ return createSingleQueryBuilder(
460
+ async () => await executeThroughCollectionUniqueOrNull(db, plan),
461
+ true
462
+ );
463
+ },
464
+ first() {
465
+ return createSingleQueryBuilder(
466
+ async () => await executeThroughCollectionFirst(db, plan),
467
+ false
468
+ );
469
+ },
470
+ firstOrNull() {
471
+ return createSingleQueryBuilder(
472
+ async () => await executeThroughCollectionFirstOrNull(db, plan),
473
+ true
474
+ );
475
+ },
476
+ many() {
477
+ return createManyQueryBuilder(
478
+ async () => await executeThroughCollectionMany(db, plan)
479
+ );
480
+ }
481
+ };
482
+ }
483
+ function createThroughManyQueryBuilder(db, plan) {
484
+ return {
485
+ ...createQueryNode(async () => await executeThroughManyNode(db, plan)),
486
+ _throughSourceKind: "many",
487
+ with(withBuilder) {
488
+ return createThroughManyQueryBuilder(
489
+ db,
490
+ withThroughNodeExpander(plan, withBuilder)
491
+ );
492
+ },
493
+ withSource(key) {
494
+ return createThroughManyQueryBuilder(db, withThroughNodeSourceKey(plan, key));
495
+ }
496
+ };
497
+ }
498
+ function createThroughSingleQueryBuilder(db, plan, nullable) {
499
+ return {
500
+ ...createQueryNode(async () => await executeThroughSingleNode(db, plan, nullable)),
501
+ _throughSourceKind: nullable ? "nullableSingle" : "single",
502
+ with(withBuilder) {
503
+ return createThroughSingleQueryBuilder(db, withThroughNodeExpander(plan, withBuilder), nullable);
504
+ },
505
+ withSource(key) {
506
+ return createThroughSingleQueryBuilder(db, withThroughNodeSourceKey(plan, key), nullable);
507
+ }
508
+ };
509
+ }
345
510
  function createExpandableSingleFromPlan(db, plan, nullable) {
346
511
  const execute = async () => nullable ? await executeFindOrNull(db, plan) : await executeFind(db, plan);
347
512
  return {
348
- ...createQueryNode(execute),
513
+ ...createSingleQueryBuilder(execute, nullable),
349
514
  with(withBuilder) {
350
515
  return createExpandableSingleFromPlan(
351
516
  db,
@@ -364,12 +529,14 @@ function createBatchFacade(db, plan) {
364
529
  );
365
530
  },
366
531
  many() {
367
- return createQueryNode(async () => await executeMany(db, plan));
532
+ return createManyQueryBuilder(async () => await executeMany(db, plan));
368
533
  }
369
534
  };
370
535
  }
371
536
  function createCollectionFacade(db, plan) {
372
537
  const facade = {
538
+ _plan: plan,
539
+ _table: plan.source.table,
373
540
  with(withBuilder) {
374
541
  return createCollectionFacade(
375
542
  db,
@@ -389,30 +556,33 @@ function createCollectionFacade(db, plan) {
389
556
  );
390
557
  },
391
558
  unique() {
392
- return createQueryNode(async () => await executeUnique(db, plan));
559
+ return createSingleQueryBuilder(async () => await executeUnique(db, plan), false);
393
560
  },
394
561
  uniqueOrNull() {
395
- return createQueryNode(async () => await executeUniqueOrNull(db, plan));
562
+ return createSingleQueryBuilder(
563
+ async () => await executeUniqueOrNull(db, plan),
564
+ true
565
+ );
396
566
  },
397
567
  first() {
398
- return createQueryNode(async () => await executeFirst(db, plan));
568
+ return createSingleQueryBuilder(async () => await executeFirst(db, plan), false);
399
569
  },
400
570
  firstOrNull() {
401
- return createQueryNode(async () => await executeFirstOrNull(db, plan));
571
+ return createSingleQueryBuilder(
572
+ async () => await executeFirstOrNull(db, plan),
573
+ true
574
+ );
402
575
  },
403
576
  take(count) {
404
- return executeTake(db, plan, count);
577
+ return createManyQueryBuilder(async () => await executeTake(db, plan, count));
405
578
  },
406
579
  paginate(opts) {
407
580
  return executePaginate(db, plan, opts);
408
581
  },
409
582
  many() {
410
- return createQueryNode(async () => await executeMany(db, plan));
583
+ return createManyQueryBuilder(async () => await executeMany(db, plan));
411
584
  }
412
585
  };
413
- if (plan.source.kind === "via") {
414
- facade.withSource = (key) => createCollectionFacade(db, withSourceKey(plan, key));
415
- }
416
586
  return facade;
417
587
  }
418
588
  function createIdPlan(table, id) {
@@ -438,15 +608,23 @@ function createBatchPlan(table, index, values) {
438
608
  values
439
609
  });
440
610
  }
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
- });
611
+ function isCollectionSource(value) {
612
+ return typeof value === "object" && value !== null && "_plan" in value && "_table" in value;
613
+ }
614
+ function isManySourceNode(value) {
615
+ return typeof value === "object" && value !== null && "_executeRoot" in value && value._throughSourceKind === "many";
616
+ }
617
+ function isSingleSourceNode(value) {
618
+ return typeof value === "object" && value !== null && "_executeRoot" in value && (value._throughSourceKind === "single" || value._throughSourceKind === "nullableSingle");
619
+ }
620
+ function normalizeIndexSelectorArgs(args) {
621
+ if (args.length === 0) {
622
+ return void 0;
623
+ }
624
+ if (args.length === 1) {
625
+ return args[0];
626
+ }
627
+ return args;
450
628
  }
451
629
  function createTableNamespace(db, table) {
452
630
  const rootFacade = createCollectionFacade(db, createQueryPlan(table));
@@ -461,8 +639,36 @@ function createTableNamespace(db, table) {
461
639
  in(ids) {
462
640
  return createBatchFacade(db, createBatchPlan(table, "by_id", ids));
463
641
  },
464
- via(joinTable, targetField) {
465
- return createViaNamespace(db, table, joinTable, targetField);
642
+ through(sourceQuery, targetField) {
643
+ if (isCollectionSource(sourceQuery)) {
644
+ return createThroughCollectionFacade(db, {
645
+ targetTable: table,
646
+ targetField,
647
+ sourcePlan: sourceQuery._plan,
648
+ expanders: []
649
+ });
650
+ }
651
+ if (isManySourceNode(sourceQuery)) {
652
+ return createThroughManyQueryBuilder(db, {
653
+ targetTable: table,
654
+ targetField,
655
+ sourceNode: sourceQuery,
656
+ expanders: []
657
+ });
658
+ }
659
+ if (isSingleSourceNode(sourceQuery)) {
660
+ return createThroughSingleQueryBuilder(
661
+ db,
662
+ {
663
+ targetTable: table,
664
+ targetField,
665
+ sourceNode: sourceQuery,
666
+ expanders: []
667
+ },
668
+ sourceQuery._throughSourceKind === "nullableSingle"
669
+ );
670
+ }
671
+ throw new Error("through() requires a query facade or lazy query node");
466
672
  }
467
673
  };
468
674
  return new Proxy(target, {
@@ -473,9 +679,13 @@ function createTableNamespace(db, table) {
473
679
  if (RESERVED_PROMISE_KEYS.has(prop) || prop === "all") {
474
680
  return void 0;
475
681
  }
476
- const indexMethod = ((value) => createCollectionFacade(
682
+ const indexMethod = ((...args) => createCollectionFacade(
477
683
  db,
478
- createQueryPlan(table, prop, value)
684
+ createQueryPlan(
685
+ table,
686
+ prop,
687
+ normalizeIndexSelectorArgs(args)
688
+ )
479
689
  ));
480
690
  indexMethod.in = (values) => createBatchFacade(
481
691
  db,
@@ -485,28 +695,6 @@ function createTableNamespace(db, table) {
485
695
  }
486
696
  });
487
697
  }
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
698
  function createQueryFacade(db) {
511
699
  return new Proxy(
512
700
  {},
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davidtkramer/convex-relations",
3
- "version": "0.1.0",
3
+ "version": "0.2.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"