@davidtkramer/convex-relations 0.2.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -126,8 +126,6 @@ That works, but you are responsible for:
126
126
  - [Error Semantics](#error-semantics)
127
127
  - [Performance Characteristics](#performance-characteristics)
128
128
  - [Comparison to `convex-helpers/server/relationships`](#comparison-to-convex-helpersserverrelationships)
129
- - [Type Notes](#type-notes)
130
- - [License](#license)
131
129
 
132
130
  ## Installation
133
131
 
@@ -157,12 +155,13 @@ wrappers. A minimal setup looks like this:
157
155
  import { customCtx, customQuery } from "convex-helpers/server/customFunctions";
158
156
  import { query as baseQuery } from "./_generated/server";
159
157
  import type { DataModel } from "./_generated/dataModel";
158
+ import schema from "../schema";
160
159
  import { createQueryFacade } from "@davidtkramer/convex-relations";
161
160
 
162
161
  export const query = customQuery(
163
162
  baseQuery,
164
163
  customCtx((ctx: { db: any }) => ({
165
- q: createQueryFacade<DataModel>(ctx.db),
164
+ q: createQueryFacade<DataModel>(ctx.db, schema),
166
165
  })),
167
166
  );
168
167
  ```
@@ -223,18 +222,26 @@ Single-field indexes accept a scalar. Compound indexes accept positional
223
222
  arguments in index order. Zero-argument calls give you the indexed range so you
224
223
  can filter, sort, paginate, or take a subset.
225
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
+
226
229
  ### `with(...)` builds nested result shapes
227
230
 
228
231
  `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.
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(...)`.
231
235
 
232
236
  ```ts
233
237
  const post = await ctx.q.posts
234
238
  .bySlug("hello-world")
235
- .with((post) => ({
239
+ .with((post, { defer }) => ({
236
240
  author: ctx.q.authors.find(post.authorId),
237
241
  comments: ctx.q.comments.byPostId(post._id).take(10),
242
+ readingTimeMinutes: defer(() =>
243
+ Math.ceil(post.body.split(/\s+/).length / 200),
244
+ ),
238
245
  }))
239
246
  .unique();
240
247
  ```
@@ -259,26 +266,30 @@ of the result tree parallelizes across its sibling fields.
259
266
 
260
267
  ## API
261
268
 
262
- ### `createQueryFacade<DataModel>(db)`
269
+ ### `createQueryFacade<DataModel>(db, schema)`
263
270
 
264
- 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.
265
274
 
266
275
  ```ts
267
276
  import { createQueryFacade } from "@davidtkramer/convex-relations";
268
277
  import type { DataModel } from "./_generated/dataModel";
278
+ import schema from "../schema";
269
279
 
270
- const q = createQueryFacade<DataModel>(ctx.db);
280
+ const q = createQueryFacade<DataModel>(ctx.db, schema);
271
281
  ```
272
282
 
273
- ### `compute(load)`
283
+ ### `with(..., { defer })`
274
284
 
275
- 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.
276
287
 
277
288
  ```ts
278
289
  const post = await q.posts
279
290
  .bySlug("hello-world")
280
- .with((post) => ({
281
- readingTimeMinutes: compute(() =>
291
+ .with((post, { defer }) => ({
292
+ readingTimeMinutes: defer(() =>
282
293
  Math.ceil(post.body.split(/\s+/).length / 200),
283
294
  ),
284
295
  }))
@@ -364,10 +375,10 @@ Batch lookups skip missing rows.
364
375
  ```ts
365
376
  const post = await q.posts
366
377
  .bySlug("hello-world")
367
- .with((post) => ({
378
+ .with((post, { defer }) => ({
368
379
  author: q.authors.find(post.authorId),
369
380
  comments: q.comments.byPostId(post._id).order("desc").take(10),
370
- commentCount: compute(async () => {
381
+ commentCount: defer(async () => {
371
382
  const comments = await q.comments.byPostId(post._id).many();
372
383
  return comments.length;
373
384
  }),
@@ -407,12 +418,12 @@ const categories = await q.categories
407
418
  This is essentially syntactic sugar for "run the source query, extract ids from
408
419
  that field, then load the target rows for you."
409
420
 
410
- You can also attach the source row with `withSource(...)`:
421
+ You can also attach the source row through the normal `with(...)` callback:
411
422
 
412
423
  ```ts
413
424
  const categories = await q.categories
414
425
  .through(q.postCategories.byPostId(postId).order("desc"), "categoryId")
415
- .withSource("link")
426
+ .with((category, { source }) => ({ link: source }))
416
427
  .many();
417
428
 
418
429
  categories[0]?.link.postId;
@@ -427,7 +438,7 @@ timestamps.
427
438
  ```ts
428
439
  const author = await q.authors
429
440
  .through(q.posts.bySlug("hello-world"), "authorId")
430
- .withSource("post")
441
+ .with((author, { source }) => ({ post: source }))
431
442
  .unique();
432
443
 
433
444
  author.post.slug;
@@ -445,12 +456,12 @@ const tags = await q.tags
445
456
  .take(10),
446
457
  "tagId",
447
458
  )
448
- .withSource("link")
459
+ .with((tag, { source }) => ({ link: source }))
449
460
  .many();
450
461
  ```
451
462
 
452
463
  After `through(...)`, you can keep shaping the target result with `with(...)`
453
- and `withSource(...)`, then choose a terminal like `many()` or `first()`.
464
+ and then choose a terminal like `many()` or `first()`.
454
465
 
455
466
  ## Terminals
456
467
 
@@ -517,10 +528,10 @@ const page = await q.posts.byAuthorId(authorId).paginate({
517
528
  Within a single `with(...)` stage, every field in the returned object runs in parallel.
518
529
 
519
530
  ```ts
520
- const post = await q.posts.find(postId).with((post) => ({
531
+ const post = await q.posts.find(postId).with((post, { defer }) => ({
521
532
  author: q.authors.find(post.authorId),
522
533
  comments: q.comments.byPostId(post._id).take(10),
523
- categoryCount: compute(async () => {
534
+ categoryCount: defer(async () => {
524
535
  const categories = await q.categories
525
536
  .through(q.postCategories.byPostId(post._id), "categoryId")
526
537
  .many();
@@ -586,15 +597,3 @@ const categories = await getManyVia(
586
597
  ```
587
598
 
588
599
  but also composes naturally with `with(...)`, `take(...)`, `firstOrNull()`, and typed nested traversal.
589
-
590
- ## Type Notes
591
-
592
- - The facade is generic over your generated `DataModel`
593
- - Table names, `_id` types, index names, and compound index prefixes are inferred
594
- - Invalid table names and invalid index names are rejected at compile time
595
- - Scalar shorthand is only allowed for single-field indexes
596
- - Compound indexes use leading positional arguments
597
-
598
- ## License
599
-
600
- MIT
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { GenericDataModel, TableNamesInDataModel, DocumentByName, GenericTableInfo, FilterBuilder, ExpressionOrValue, NamedTableInfo, IndexNames, IndexRangeBuilder, NamedIndex, IndexRange, GenericDatabaseReader } from 'convex/server';
1
+ import { GenericDataModel, TableNamesInDataModel, DocumentByName, GenericTableInfo, FilterBuilder, ExpressionOrValue, NamedTableInfo, IndexNames, IndexRangeBuilder, NamedIndex, IndexRange, GenericDatabaseReader, SchemaDefinition } from 'convex/server';
2
2
  import { GenericId } from 'convex/values';
3
3
 
4
4
  type Simplify<T> = {
@@ -43,16 +43,20 @@ type QueryPlanHandle<DataModel extends GenericDataModel, Table extends AppTable<
43
43
  readonly _plan: QueryPlan;
44
44
  readonly _table: Table;
45
45
  };
46
- type WithSpec = Record<string, QueryNode<any>>;
47
- type WithBuilder<ParentItem, Spec extends WithSpec | undefined = WithSpec | undefined> = (parent: ParentItem) => Spec;
48
- type AnyWithBuilder<ParentItem> = WithBuilder<ParentItem, WithSpec | undefined>;
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>;
49
56
  type BuiltWithSpec<Builder> = Builder extends (...args: any[]) => infer Spec ? Spec : never;
50
57
  type ExpandWith<ParentItem, Builder> = Simplify<ParentItem & (BuiltWithSpec<Builder> extends Record<string, unknown> ? {
51
- [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];
52
59
  } : {})>;
53
- type AttachSource<ParentItem, SourceItem, SourceKey extends string> = Simplify<ParentItem & {
54
- [K in SourceKey]: SourceItem;
55
- }>;
56
60
  type PaginationOptions = {
57
61
  numItems: number;
58
62
  cursor: string | null;
@@ -89,12 +93,10 @@ type ThroughSourceField<DataModel extends GenericDataModel, TargetTable extends
89
93
  [Field in Extract<keyof SourceItem, string>]: IdTargetTable<DataModel, SourceItem[Field]> extends TargetTable ? Field : never;
90
94
  }[Extract<keyof SourceItem, string>];
91
95
  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>;
96
+ with<Builder extends AnyWithBuilder<Item, WithContext<SourceItem>>>(withBuilder: Builder): ManyThroughQueryBuilder<ExpandWith<Item, Builder>, SourceItem>;
94
97
  };
95
98
  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>;
99
+ with<Builder extends AnyWithBuilder<Item, WithContext<SourceItem>>>(withBuilder: Builder): SingleThroughQueryBuilder<ExpandWith<Item, Builder>, SourceItem, Nullable>;
98
100
  };
99
101
  type TableQueryFacade<DataModel extends GenericDataModel, Table extends AppTable<DataModel>, Item = AppDoc<DataModel, Table>> = {
100
102
  with<Builder extends AnyWithBuilder<Item>>(withBuilder: Builder): TableQueryFacade<DataModel, Table, ExpandWith<Item, Builder>>;
@@ -125,8 +127,7 @@ type TableBatchQueryFacade<DataModel extends GenericDataModel, Table extends App
125
127
  many(): BatchQueryBuilder<Item>;
126
128
  };
127
129
  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>>;
130
+ with<Builder extends AnyWithBuilder<Item, WithContext<SourceItem>>>(withBuilder: Builder): ThroughQueryFacade<DataModel, TargetTable, SourceItem, ExpandWith<Item, Builder>>;
130
131
  unique(): UniqueQueryBuilder<Item>;
131
132
  uniqueOrNull(): UniqueOrNullQueryBuilder<Item>;
132
133
  first(): FirstQueryBuilder<Item>;
@@ -164,18 +165,19 @@ type QuerySourcePlan = {
164
165
  table: string;
165
166
  index?: string;
166
167
  selector?: unknown;
168
+ indexFields?: readonly string[];
167
169
  } | {
168
170
  kind: 'batch';
169
171
  table: string;
170
172
  index: string;
171
173
  values: unknown[];
174
+ indexFields?: readonly string[];
172
175
  };
173
176
  type QueryPlan = {
174
177
  source: QuerySourcePlan;
175
178
  modifiers: QueryModifier[];
176
- expanders: AnyWithBuilder<any>[];
179
+ expanders: AnyWithBuilder<any, any>[];
177
180
  };
178
- declare function createQueryFacade<DataModel extends GenericDataModel>(db: GenericDatabaseReader<DataModel>): QueryFacade<DataModel>;
179
- declare function compute<Output = unknown>(load: () => Promise<Output> | Output): QueryNode<Output>;
181
+ declare function createQueryFacade<DataModel extends GenericDataModel>(db: GenericDatabaseReader<DataModel>, schema: SchemaDefinition<any, boolean>): QueryFacade<DataModel>;
180
182
 
181
- export { type AppDoc, type AppTable, type QueryFacade, type RootIndexValueArg, type StrictRootIndexValueArg, type UserIndex, compute, createQueryFacade };
183
+ 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,16 +67,15 @@ function withExpander(plan, expander) {
52
67
  expanders: [...plan.expanders, expander]
53
68
  };
54
69
  }
55
- function normalizeIndexValues(index, value) {
70
+ function normalizeIndexValues(index, value, indexFields) {
56
71
  if (Array.isArray(value)) {
57
- const fieldNames = inferFieldNamesFromIndex(index);
58
- return Object.fromEntries(fieldNames.map((field, index2) => [field, value[index2]]));
72
+ return Object.fromEntries(indexFields.map((field, index2) => [field, value[index2]]));
59
73
  }
60
74
  if (isPlainObject(value)) {
61
75
  return value;
62
76
  }
63
77
  return {
64
- [inferFieldNameFromIndex(index)]: value
78
+ [index === "by_id" ? "_id" : indexFields[0]]: value
65
79
  };
66
80
  }
67
81
  function isPlainObject(value) {
@@ -74,19 +88,16 @@ function applyIndexValues(query, values) {
74
88
  }
75
89
  return current;
76
90
  }
77
- function inferFieldNameFromIndex(index) {
78
- return inferFieldNamesFromIndex(index)[0];
79
- }
80
- function inferFieldNamesFromIndex(index) {
91
+ function requireIndexFields(index, indexFields) {
81
92
  if (index === "by_id") {
82
93
  return ["_id"];
83
94
  }
84
- if (index.startsWith("by") && index.length > 2) {
85
- return index.slice(2).split("And").map((part) => `${part[0].toLowerCase()}${part.slice(1)}`);
95
+ if (indexFields === void 0) {
96
+ throw new Error(`Missing schema metadata for index ${index}`);
86
97
  }
87
- throw new Error(`Cannot infer field name from index ${index}`);
98
+ return indexFields;
88
99
  }
89
- function createIndexedQuery(db, table, index, selector) {
100
+ function createIndexedQuery(db, table, index, selector, indexFields = []) {
90
101
  const baseQuery = db.query(table);
91
102
  if (index === void 0) {
92
103
  return baseQuery;
@@ -100,9 +111,10 @@ function createIndexedQuery(db, table, index, selector) {
100
111
  if (index === "by_id") {
101
112
  return baseQuery.withIndex(index, (q) => q.eq("_id", selector));
102
113
  }
114
+ const fields = requireIndexFields(index, indexFields);
103
115
  return baseQuery.withIndex(
104
116
  index,
105
- (q) => applyIndexValues(q, normalizeIndexValues(index, selector))
117
+ (q) => applyIndexValues(q, normalizeIndexValues(index, selector, fields))
106
118
  );
107
119
  }
108
120
  function sourceDescription(plan) {
@@ -139,7 +151,13 @@ async function* iterateDecoratedSourceItems(db, plan) {
139
151
  const runtime = createPlanRuntime(db, plan);
140
152
  const source = plan.source;
141
153
  const query = buildQuery(
142
- () => createIndexedQuery(db, source.table, source.index, source.selector),
154
+ () => createIndexedQuery(
155
+ db,
156
+ source.table,
157
+ source.index,
158
+ source.selector,
159
+ source.indexFields
160
+ ),
143
161
  plan.modifiers
144
162
  );
145
163
  for await (const rawItem of query) {
@@ -176,18 +194,28 @@ function createPlanRuntime(db, plan) {
176
194
  return {
177
195
  many: async () => (await Promise.all(
178
196
  source.values.map(
179
- async (value) => await queryUniqueByIndex(db, source.table, source.index, value)
197
+ async (value) => await queryManyByIndex(
198
+ db,
199
+ source.table,
200
+ source.index,
201
+ value,
202
+ source.indexFields
203
+ )
180
204
  )
181
- )).filter(
182
- (doc) => doc !== null
183
- ),
205
+ )).flat(),
184
206
  mapOne: async (rawItem) => rawItem,
185
207
  mapMany: async (rawItems) => rawItems,
186
208
  missingMessages: {}
187
209
  };
188
210
  case "query": {
189
211
  const runQuery = () => buildQuery(
190
- () => createIndexedQuery(db, source.table, source.index, source.selector),
212
+ () => createIndexedQuery(
213
+ db,
214
+ source.table,
215
+ source.index,
216
+ source.selector,
217
+ source.indexFields
218
+ ),
191
219
  plan.modifiers
192
220
  );
193
221
  return {
@@ -209,14 +237,18 @@ function createPlanRuntime(db, plan) {
209
237
  async function decorateItem(plan, rawItem, item) {
210
238
  let output = item;
211
239
  if (plan.expanders.length > 0) {
212
- output = await applyExpanders(output, plan.expanders);
240
+ output = await applyExpanders(output, plan.expanders, createWithContext());
213
241
  }
214
242
  return output;
215
243
  }
216
244
  async function decorateItems(plan, rawItems, items) {
217
245
  let output = items;
218
246
  if (plan.expanders.length > 0) {
219
- output = await applyExpandersToMany(output, plan.expanders);
247
+ output = await applyExpandersToMany(
248
+ output,
249
+ plan.expanders,
250
+ () => createWithContext()
251
+ );
220
252
  }
221
253
  return output;
222
254
  }
@@ -310,22 +342,25 @@ function createManyQueryBuilder(executeRoot) {
310
342
  _throughSourceKind: "many"
311
343
  };
312
344
  }
313
- async function decorateThroughItem(expanders, sourceKey, pair) {
345
+ async function decorateThroughItem(expanders, pair) {
314
346
  let output = pair.target;
315
- if (sourceKey) {
316
- output = { ...output, [sourceKey]: pair.source };
317
- }
318
347
  if (expanders.length > 0) {
319
- output = await applyExpanders(output, expanders);
348
+ output = await applyExpanders(
349
+ output,
350
+ expanders,
351
+ createWithContext(pair.source)
352
+ );
320
353
  }
321
354
  return output;
322
355
  }
323
- async function decorateThroughItems(expanders, sourceKey, pairs) {
324
- let output = pairs.map(
325
- ({ source, target }) => sourceKey ? { ...target, [sourceKey]: source } : target
326
- );
356
+ async function decorateThroughItems(expanders, pairs) {
357
+ let output = pairs.map(({ target }) => target);
327
358
  if (expanders.length > 0) {
328
- output = await applyExpandersToMany(output, expanders);
359
+ output = await applyExpandersToMany(
360
+ output,
361
+ expanders,
362
+ (_item, index) => createWithContext(pairs[index].source)
363
+ );
329
364
  }
330
365
  return output;
331
366
  }
@@ -336,7 +371,7 @@ async function executeThroughCollectionMany(db, plan) {
336
371
  plan.targetField,
337
372
  sourceItems
338
373
  );
339
- return await decorateThroughItems(plan.expanders, plan.sourceKey, pairs);
374
+ return await decorateThroughItems(plan.expanders, pairs);
340
375
  }
341
376
  async function executeThroughCollectionFirst(db, plan) {
342
377
  const pair = (await collectThroughPairsUntil(
@@ -348,7 +383,7 @@ async function executeThroughCollectionFirst(db, plan) {
348
383
  if (!pair) {
349
384
  throw new Error(`Could not find first ${plan.targetTable} through ${sourceDescription(plan.sourcePlan)}`);
350
385
  }
351
- return await decorateThroughItem(plan.expanders, plan.sourceKey, pair);
386
+ return await decorateThroughItem(plan.expanders, pair);
352
387
  }
353
388
  async function executeThroughCollectionFirstOrNull(db, plan) {
354
389
  const pair = (await collectThroughPairsUntil(
@@ -357,7 +392,7 @@ async function executeThroughCollectionFirstOrNull(db, plan) {
357
392
  iterateDecoratedSourceItems(db, plan.sourcePlan),
358
393
  1
359
394
  ))[0];
360
- return pair ? await decorateThroughItem(plan.expanders, plan.sourceKey, pair) : null;
395
+ return pair ? await decorateThroughItem(plan.expanders, pair) : null;
361
396
  }
362
397
  async function executeThroughCollectionUnique(db, plan) {
363
398
  const pairs = await collectThroughPairsUntil(
@@ -373,7 +408,7 @@ async function executeThroughCollectionUnique(db, plan) {
373
408
  if (!pair) {
374
409
  throw new Error(`Could not find ${plan.targetTable} through ${sourceDescription(plan.sourcePlan)}`);
375
410
  }
376
- return await decorateThroughItem(plan.expanders, plan.sourceKey, pair);
411
+ return await decorateThroughItem(plan.expanders, pair);
377
412
  }
378
413
  async function executeThroughCollectionUniqueOrNull(db, plan) {
379
414
  const pairs = await collectThroughPairsUntil(
@@ -385,7 +420,7 @@ async function executeThroughCollectionUniqueOrNull(db, plan) {
385
420
  if (pairs.length > 1) {
386
421
  throw new Error("unique() returned more than one result");
387
422
  }
388
- return pairs[0] ? await decorateThroughItem(plan.expanders, plan.sourceKey, pairs[0]) : null;
423
+ return pairs[0] ? await decorateThroughItem(plan.expanders, pairs[0]) : null;
389
424
  }
390
425
  async function executeThroughManyNode(db, plan) {
391
426
  const sourceItems = await plan.sourceNode._executeRoot();
@@ -394,7 +429,7 @@ async function executeThroughManyNode(db, plan) {
394
429
  plan.targetField,
395
430
  sourceItems
396
431
  );
397
- return await decorateThroughItems(plan.expanders, plan.sourceKey, pairs);
432
+ return await decorateThroughItems(plan.expanders, pairs);
398
433
  }
399
434
  async function executeThroughSingleNode(db, plan, nullable) {
400
435
  const sourceItem = await plan.sourceNode._executeRoot();
@@ -415,7 +450,7 @@ async function executeThroughSingleNode(db, plan, nullable) {
415
450
  }
416
451
  throw new Error(`Could not find ${plan.targetTable} through source query`);
417
452
  }
418
- return await decorateThroughItem(plan.expanders, plan.sourceKey, pair);
453
+ return await decorateThroughItem(plan.expanders, pair);
419
454
  }
420
455
  function withThroughCollectionExpander(plan, expander) {
421
456
  return {
@@ -423,32 +458,17 @@ function withThroughCollectionExpander(plan, expander) {
423
458
  expanders: [...plan.expanders, expander]
424
459
  };
425
460
  }
426
- function withThroughCollectionSourceKey(plan, sourceKey) {
427
- return {
428
- ...plan,
429
- sourceKey
430
- };
431
- }
432
461
  function withThroughNodeExpander(plan, expander) {
433
462
  return {
434
463
  ...plan,
435
464
  expanders: [...plan.expanders, expander]
436
465
  };
437
466
  }
438
- function withThroughNodeSourceKey(plan, sourceKey) {
439
- return {
440
- ...plan,
441
- sourceKey
442
- };
443
- }
444
467
  function createThroughCollectionFacade(db, plan) {
445
468
  return {
446
469
  with(withBuilder) {
447
470
  return createThroughCollectionFacade(db, withThroughCollectionExpander(plan, withBuilder));
448
471
  },
449
- withSource(key) {
450
- return createThroughCollectionFacade(db, withThroughCollectionSourceKey(plan, key));
451
- },
452
472
  unique() {
453
473
  return createSingleQueryBuilder(
454
474
  async () => await executeThroughCollectionUnique(db, plan),
@@ -489,9 +509,6 @@ function createThroughManyQueryBuilder(db, plan) {
489
509
  db,
490
510
  withThroughNodeExpander(plan, withBuilder)
491
511
  );
492
- },
493
- withSource(key) {
494
- return createThroughManyQueryBuilder(db, withThroughNodeSourceKey(plan, key));
495
512
  }
496
513
  };
497
514
  }
@@ -501,9 +518,6 @@ function createThroughSingleQueryBuilder(db, plan, nullable) {
501
518
  _throughSourceKind: nullable ? "nullableSingle" : "single",
502
519
  with(withBuilder) {
503
520
  return createThroughSingleQueryBuilder(db, withThroughNodeExpander(plan, withBuilder), nullable);
504
- },
505
- withSource(key) {
506
- return createThroughSingleQueryBuilder(db, withThroughNodeSourceKey(plan, key), nullable);
507
521
  }
508
522
  };
509
523
  }
@@ -592,20 +606,22 @@ function createIdPlan(table, id) {
592
606
  id
593
607
  });
594
608
  }
595
- function createQueryPlan(table, index, selector) {
609
+ function createQueryPlan(table, index, selector, indexFields) {
596
610
  return createPlan({
597
611
  kind: "query",
598
612
  table,
599
613
  index,
600
- selector
614
+ selector,
615
+ indexFields
601
616
  });
602
617
  }
603
- function createBatchPlan(table, index, values) {
618
+ function createBatchPlan(table, index, values, indexFields) {
604
619
  return createPlan({
605
620
  kind: "batch",
606
621
  table,
607
622
  index,
608
- values
623
+ values,
624
+ indexFields
609
625
  });
610
626
  }
611
627
  function isCollectionSource(value) {
@@ -626,7 +642,7 @@ function normalizeIndexSelectorArgs(args) {
626
642
  }
627
643
  return args;
628
644
  }
629
- function createTableNamespace(db, table) {
645
+ function createTableNamespace(db, table, resolveIndexFields) {
630
646
  const rootFacade = createCollectionFacade(db, createQueryPlan(table));
631
647
  const target = {
632
648
  ...rootFacade,
@@ -684,18 +700,49 @@ function createTableNamespace(db, table) {
684
700
  createQueryPlan(
685
701
  table,
686
702
  prop,
687
- normalizeIndexSelectorArgs(args)
703
+ normalizeIndexSelectorArgs(args),
704
+ resolveIndexFields(table, prop)
688
705
  )
689
706
  ));
690
707
  indexMethod.in = (values) => createBatchFacade(
691
708
  db,
692
- createBatchPlan(table, prop, values)
709
+ createBatchPlan(
710
+ table,
711
+ prop,
712
+ values,
713
+ resolveIndexFields(table, prop)
714
+ )
693
715
  );
694
716
  return indexMethod;
695
717
  }
696
718
  });
697
719
  }
698
- function createQueryFacade(db) {
720
+ function createIndexFieldResolver(schema) {
721
+ const cache = /* @__PURE__ */ new Map();
722
+ return (table, index) => {
723
+ if (index === "by_id") {
724
+ return ["_id"];
725
+ }
726
+ const cacheKey = `${table}:${index}`;
727
+ const cached = cache.get(cacheKey);
728
+ if (cached !== void 0) {
729
+ return cached;
730
+ }
731
+ const tableDefinition = schema.tables[table];
732
+ const indexes = tableDefinition?.[" indexes"]();
733
+ const match = indexes?.find(
734
+ (candidate) => candidate.indexDescriptor === index
735
+ );
736
+ if (match !== void 0 && Array.isArray(match.fields)) {
737
+ const fields = [...match.fields];
738
+ cache.set(cacheKey, fields);
739
+ return fields;
740
+ }
741
+ throw new Error(`Missing schema metadata for index ${table}.${index}`);
742
+ };
743
+ }
744
+ function createQueryFacade(db, schema) {
745
+ const resolveIndexFields = createIndexFieldResolver(schema);
699
746
  return new Proxy(
700
747
  {},
701
748
  {
@@ -703,24 +750,26 @@ function createQueryFacade(db) {
703
750
  if (typeof prop !== "string" || RESERVED_PROMISE_KEYS.has(prop)) {
704
751
  return void 0;
705
752
  }
706
- return createTableNamespace(db, prop);
753
+ return createTableNamespace(
754
+ db,
755
+ prop,
756
+ resolveIndexFields
757
+ );
707
758
  }
708
759
  }
709
760
  );
710
761
  }
711
- function compute(load) {
712
- return createQueryNode(async () => await load());
713
- }
714
- async function queryUniqueByIndex(db, table, index, value) {
762
+ async function queryManyByIndex(db, table, index, value, indexFields) {
715
763
  if (index === "by_id") {
716
- return await db.query(table).withIndex("by_id", (q) => q.eq("_id", value)).unique();
764
+ const doc = await db.query(table).withIndex("by_id", (q) => q.eq("_id", value)).unique();
765
+ return doc ? [doc] : [];
717
766
  }
767
+ const fields = requireIndexFields(index, indexFields);
718
768
  return await db.query(table).withIndex(
719
769
  index,
720
- (q) => applyIndexValues(q, normalizeIndexValues(index, value))
721
- ).unique();
770
+ (q) => applyIndexValues(q, normalizeIndexValues(index, value, fields))
771
+ ).collect();
722
772
  }
723
773
  export {
724
- compute,
725
774
  createQueryFacade
726
775
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davidtkramer/convex-relations",
3
- "version": "0.2.0",
3
+ "version": "0.4.1",
4
4
  "description": "Typed query facade helpers for Convex backends",
5
5
  "type": "module",
6
6
  "license": "MIT",