pbtsdb 0.8.0 → 0.9.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
@@ -10,7 +10,7 @@ A TypeScript library that seamlessly integrates [PocketBase](https://pocketbase.
10
10
  - 🔄 **Automatic caching** via TanStack Query
11
11
  - ✨ **Optimistic mutations** with insert/update/delete support
12
12
  - 🎨 **React hooks** for easy component integration
13
- - 🔗 **Type-safe joins** and relation expansion
13
+ - 🔗 **Type-safe joins** and relation fetching into their own collections
14
14
 
15
15
  ## Table of Contents
16
16
 
@@ -19,34 +19,33 @@ A TypeScript library that seamlessly integrates [PocketBase](https://pocketbase.
19
19
  - [Core Concepts](#core-concepts)
20
20
  - [API Reference](#api-reference)
21
21
  - [createCollection()](#createcollection)
22
+ - [Related data](#related-data)
22
23
  - [React Integration](#react-integration)
23
24
  - [Subscriptions](#subscriptions)
25
+ - [Utility Functions](#utility-functions)
24
26
  - [Usage Examples](#usage-examples)
25
- - [Basic Queries](#basic-queries)
26
- - [Filtering and Sorting](#filtering-and-sorting)
27
- - [Relations and Joins](#relations-and-joins)
28
27
  - [Includes (Nested Subqueries)](#includes-nested-subqueries)
29
- - [Real-time Updates](#real-time-updates)
30
- - [Mutations](#mutations)
31
28
  - [TypeScript](#typescript)
32
29
  - [Best Practices](#best-practices)
30
+ - [Configuration](#configuration)
33
31
 
34
32
  ## Installation
35
33
 
36
34
  ```bash
37
- npm install pbtsdb pocketbase @tanstack/react-query @tanstack/react-db @tanstack/query-db-collection
35
+ npm install pbtsdb pocketbase @tanstack/db @tanstack/query-db-collection @tanstack/react-query @tanstack/react-db
38
36
  ```
39
37
 
40
38
  ### Peer Dependencies
41
39
 
42
40
  - `pocketbase` >= 0.22.0
41
+ - `@tanstack/db` >= 0.6.0
42
+ - `@tanstack/query-db-collection` >= 1.0.40
43
43
  - `@tanstack/react-query` >= 5.0.0
44
- - `@tanstack/react-db` >= 0.1.0
45
- - `@tanstack/query-db-collection` >= 1.0.0
46
- - `react` >= 18.0.0
47
- - `react-dom` >= 18.0.0
44
+ - `@tanstack/react-db` >= 0.1.86 (optional; only for `createReactProvider`)
45
+ - `react` and `react-dom` >= 18.0.0 (optional)
48
46
 
49
- All peer dependencies use minimum version constraints - newer versions should work.
47
+ All peer dependencies use minimum version constraints; newer versions should work. The
48
+ non-React entry point `pbtsdb/core` needs neither `react` nor `@tanstack/react-db`.
50
49
 
51
50
  ## Quick Start
52
51
 
@@ -129,12 +128,12 @@ export const { Provider, useStore } = createReactProvider({
129
128
  posts: c('posts', {
130
129
  omitOnInsert: ['created', 'updated'] as const,
131
130
  relations: { author: users },
132
- alwaysExpand: ['author'],
131
+ alwaysFetchRelations: ['author'],
133
132
  }),
134
133
  comments: c('comments', {
135
134
  omitOnInsert: ['created', 'updated'] as const,
136
135
  relations: { author: users },
137
- alwaysExpand: ['author'],
136
+ alwaysFetchRelations: ['author'],
138
137
  }),
139
138
  });
140
139
 
@@ -154,14 +153,22 @@ export function App() {
154
153
  ```typescript
155
154
  // BlogDashboard.tsx
156
155
  import { useLiveQuery } from '@tanstack/react-db';
156
+ import { eq } from '@tanstack/db';
157
+ import { materialize } from 'pbtsdb';
157
158
  import { useStore } from './app';
158
159
 
159
160
  export function BlogDashboard() {
160
- const [posts] = useStore('posts');
161
+ const [posts, users] = useStore('posts', 'users');
161
162
 
162
163
  const { data: allPosts, isLoading } = useLiveQuery((q) =>
163
164
  q.from({ posts })
164
165
  .orderBy(({ posts }) => posts.created, 'desc')
166
+ .select(({ posts }) => ({
167
+ ...posts,
168
+ author: materialize(
169
+ q.from({ u: users }).where(({ u }) => eq(u.id, posts.author)).findOne()
170
+ ),
171
+ }))
165
172
  );
166
173
 
167
174
  if (isLoading) return <div>Loading posts...</div>;
@@ -173,8 +180,8 @@ export function BlogDashboard() {
173
180
  <article key={post.id}>
174
181
  <h2>{post.title}</h2>
175
182
  <p>{post.content}</p>
176
- {/* Expanded author is fully typed! */}
177
- <small>By {post.expand?.author?.username}</small>
183
+ {/* Author was filed into the users collection by alwaysFetchRelations */}
184
+ <small>By {post.author?.username}</small>
178
185
  </article>
179
186
  ))}
180
187
  </div>
@@ -189,16 +196,22 @@ export function BlogDashboard() {
189
196
  import { useLiveQuery } from '@tanstack/react-db';
190
197
  import { eq } from '@tanstack/db';
191
198
  import { useStore } from './app';
192
- import { newRecordId } from 'pbtsdb';
199
+ import { materialize, newRecordId } from 'pbtsdb';
193
200
 
194
201
  export function PostWithComments({ postId }: { postId: string }) {
195
- const [comments, posts] = useStore('comments', 'posts');
202
+ const [comments, posts, users] = useStore('comments', 'posts', 'users');
196
203
 
197
204
  // Real-time comments for this post
198
205
  const { data: postComments } = useLiveQuery((q) =>
199
206
  q.from({ comments })
200
207
  .where(({ comments }) => eq(comments.post, postId))
201
208
  .orderBy(({ comments }) => comments.created, 'desc')
209
+ .select(({ comments }) => ({
210
+ ...comments,
211
+ author: materialize(
212
+ q.from({ u: users }).where(({ u }) => eq(u.id, comments.author)).findOne()
213
+ ),
214
+ }))
202
215
  );
203
216
 
204
217
  const handleAddComment = (text: string, authorId: string) => {
@@ -216,7 +229,7 @@ export function PostWithComments({ postId }: { postId: string }) {
216
229
  <h3>Comments ({postComments?.length || 0})</h3>
217
230
  {postComments?.map(comment => (
218
231
  <div key={comment.id}>
219
- <strong>{comment.expand?.author?.username}:</strong>
232
+ <strong>{comment.author?.username}:</strong>
220
233
  <p>{comment.text}</p>
221
234
  </div>
222
235
  ))}
@@ -230,7 +243,7 @@ export function PostWithComments({ postId }: { postId: string }) {
230
243
  - ✅ Type-safe queries
231
244
  - ✅ Automatic real-time updates
232
245
  - ✅ Optimistic mutations
233
- - ✅ Expanded relations
246
+ - ✅ Related data read from its own collection, no embedded copies
234
247
 
235
248
  ## Core Concepts
236
249
 
@@ -273,6 +286,22 @@ const { data } = useLiveQuery((q) =>
273
286
  - **Shared:** Multiple components using the same collection share one subscription
274
287
  - **No manual control needed:** The collection handles all subscription management internally
275
288
 
289
+ ### Sync Modes
290
+
291
+ Every collection is either **eager** (the default) or **on-demand**:
292
+
293
+ ```typescript
294
+ const authors = c('authors', {}); // eager: one full fetch, then realtime
295
+ const books = c('books', { syncMode: 'on-demand' }); // on-demand: fetch what queries ask for
296
+ ```
297
+
298
+ An eager collection loads all of its rows on first use and answers every query
299
+ from memory. An on-demand collection translates each live query's `where`,
300
+ `orderBy`, and `limit` into a PocketBase request, so only the rows a query asks
301
+ for enter the store, and different filters are cached under different keys.
302
+ Use on-demand for large collections; realtime keeps both modes current once rows
303
+ are loaded.
304
+
276
305
  ### Mutations and Refetch
277
306
 
278
307
  By default, pbtsdb does **not** refetch the entire collection after a successful insert, update, or delete. The realtime subscription delivers server-confirmed rows — including server-assigned fields like `id`, `created`, `updated`, and any values rewritten by PocketBase hooks — and TanStack DB's optimistic write keeps the UI consistent in the meantime. The post-mutation refetch is therefore redundant.
@@ -325,13 +354,14 @@ const collection = c(collectionName: string, options?: CreateCollectionOptions);
325
354
  - `options` - Optional configuration
326
355
 
327
356
  **Options:**
328
- - `relations?: Record<string, Collection>` - Collections that receive expanded records for each relation; declares what `alwaysExpand` and `collection.expand()` may name
329
- - `alwaysExpand?: readonly string[]` - Expand paths applied on every fetch (e.g. `['author', 'book.author']`)
357
+ - `relations?: Record<string, Collection>` - Collections that receive expanded records for each relation; declares what `alwaysFetchRelations` and `collection.fetchRelations()` may name
358
+ - `alwaysFetchRelations?: readonly string[]` - Expand paths fetched with every request and filed into their `relations` targets; never kept on the row
330
359
  - `omitOnInsert?: readonly string[]` - Fields to make optional during insert (e.g., `['created', 'updated'] as const`)
331
360
  - `syncMode?: 'eager' | 'on-demand'` - Data fetching strategy (default: `'eager'`)
332
361
  - `onInsert?: InsertMutationFn | false` - Custom insert handler or `false` to disable
333
362
  - `onUpdate?: UpdateMutationFn | false` - Custom update handler or `false` to disable
334
363
  - `onDelete?: DeleteMutationFn | false` - Custom delete handler or `false` to disable
364
+ - `refetchOnMutation?: boolean` - Refetch the collection after a built-in insert/update/delete succeeds (default: `false`; see [Mutations and Refetch](#mutations-and-refetch))
335
365
  - `ignoreAutoCancellation?: boolean` - Ignore PocketBase auto-cancellation errors (default: `true`)
336
366
  - `collectionOptions?: object` - Additional TanStack DB collection options passed through directly (see [Collection Options Passthrough](#collection-options-passthrough))
337
367
 
@@ -345,61 +375,87 @@ const c = createCollection<MySchema>(pb, queryClient);
345
375
  const booksCollection = c('books', {});
346
376
  ```
347
377
 
348
- With always-expanded relations:
378
+ #### Related data
379
+
380
+ PocketBase `expand` is used only to bring related records into their own
381
+ collections. Rows never carry `expand`; read related records from the target
382
+ collection.
383
+
349
384
  ```typescript
350
385
  const c = createCollection<MySchema>(pb, queryClient);
351
- const authorsCollection = c('authors', {});
352
- const booksCollection = c('books', {
353
- relations: { author: authorsCollection }, // where expanded authors are upserted
354
- alwaysExpand: ['author'], // expanded on every fetch
386
+ const authors = c('authors', { syncMode: 'on-demand' });
387
+ const tags = c('tags', { syncMode: 'on-demand' });
388
+ const books = c('books', {
389
+ relations: { author: authors, tags }, // where expanded records are filed
390
+ alwaysFetchRelations: ['author'], // fetched with every books request
355
391
  });
356
-
357
- const { data } = useLiveQuery((q) => q.from({ books: booksCollection }));
358
- // data[0].expand?.author is typed and populated
359
392
  ```
360
393
 
361
- #### Per-query expand
362
-
363
- Declare relations once, then ask for expansion per query with `collection.expand()`.
364
- A view shares the collection's store, realtime subscription, and mutations; only
365
- its fetches add the `expand` parameter.
394
+ Every books request expands `author`; the expanded authors are filed into
395
+ `authors` (an on-demand target has its sync started) and removed from the
396
+ book rows. Read them through `materialize()` in a query, a join, or
397
+ `authors.get(book.author)`:
366
398
 
367
399
  ```typescript
368
- const tagsCollection = c('tags', {});
369
- const booksCollection = c('books', {
370
- relations: { author: authorsCollection, tags: tagsCollection },
371
- });
400
+ import { eq } from '@tanstack/db';
401
+ import { materialize } from 'pbtsdb';
372
402
 
373
- function BookList() {
374
- const [books] = useStore('books');
375
- const { data } = useLiveQuery((q) =>
376
- q.from({ books: books.expand('tags') })
377
- .where(({ books }) => eq(books.genre, 'Fiction'))
378
- );
379
- // data[0].expand?.tags is Tags[] | undefined; data[0].expand?.author is a type error
380
- }
403
+ const { data } = useLiveQuery((q) =>
404
+ q.from({ b: books }).select(({ b }) => ({
405
+ ...b,
406
+ author: materialize(
407
+ q.from({ a: authors }).where(({ a }) => eq(a.id, b.author)).findOne()
408
+ ),
409
+ }))
410
+ );
411
+ // data[0].author?.name is Authors | undefined and updates when the author changes
381
412
  ```
382
413
 
383
- Paths can be nested through a target collection's own `relations`:
414
+ Because the authors are already in the store, that include makes no request:
415
+ an id-only load (`eq(id, x)`, `inArray(id, [...])`, or an `or` of those) is
416
+ served from the synced store when every id is present, except when the query
417
+ reads through a `fetchRelations()` view, whose fetch goes to PocketBase so its
418
+ paths get filed; anything else is fetched in one batched request. An empty
419
+ `inArray(id, [])` yields no rows and no request.
420
+
421
+ Fetch a relation for one query only with `fetchRelations()`; the view shares the
422
+ collection's store, realtime subscription, and mutations, and only its fetches
423
+ add the `expand` parameter:
384
424
 
385
425
  ```typescript
386
- const metadata = c('book_metadata', { relations: { book: booksCollection } });
387
- const { data } = useLiveQuery((q) => q.from({ m: metadata.expand('book.author') }));
388
- // data[0].expand?.book?.expand?.author?.name
426
+ const { data } = useLiveQuery((q) => q.from({ b: books.fetchRelations('tags') }));
427
+ // tags referenced by these books are now in the tags collection
389
428
  ```
390
429
 
391
- Rows fetched through a view keep their `expand` data in the shared store, and the
392
- realtime subscription requests every relation in use, so echoes keep it populated.
430
+ Paths can be nested through a target collection's own `relations`
431
+ (`'book.author'`). While a query fetches into a target, that target keeps its
432
+ realtime subscription, so `get()` reads stay fresh.
433
+
434
+ Back-relations work the same way and go one step further. PocketBase names them
435
+ `<collection>_via_<field>`; fetching one files every child that references the
436
+ parent, and pbtsdb records that the child subset for that parent is complete,
437
+ so a child query filtered by the foreign key is served from the store:
393
438
 
394
- Relation targets stay live too. While a query expands into `tags` (or through
395
- it into `colors`), those collections keep their realtime subscriptions, and a
396
- change to a tag or a color updates the embedded copy on affected rows in
397
- place, so `book.expand?.tags?.[0].name` refreshes without a write to the book.
398
- While an optimistic mutation is pending on the parent row, TanStack DB shows
399
- the frozen optimistic snapshot, so a patched `expand` becomes visible once
400
- that mutation settles. Deleting a related record clears the reference and the
401
- copy, matching what PocketBase does server-side. Targets are released when
402
- the last query on the parent unmounts.
439
+ ```typescript
440
+ const comments = c('comments', { syncMode: 'on-demand' });
441
+ const cards = c('cards', { syncMode: 'on-demand', relations: { comments_via_card: comments } });
442
+
443
+ const { data } = useLiveQuery((q) =>
444
+ q.from({ card: cards.fetchRelations('comments_via_card') })
445
+ .where(({ card }) => eq(card.id, cardId))
446
+ .select(({ card }) => ({
447
+ ...card,
448
+ comments: materialize(q.from({ cm: comments }).where(({ cm }) => eq(cm.card, card.id))),
449
+ }))
450
+ );
451
+ // one request; the comments include reads from the store
452
+ ```
453
+
454
+ A subset stays marked while the child collection is subscribed (the parent holds
455
+ it live) and is forgotten when a child row is pruned from the store, when the
456
+ child's realtime subscription stops, or when the collection is cleaned up.
457
+ PocketBase caps a back-relation expand at 1000 records, and a capped expand is
458
+ never treated as complete.
403
459
 
404
460
  #### Collection Options Passthrough
405
461
 
@@ -581,6 +637,10 @@ both transports:
581
637
  Setting the matching header on REST requests remains the application's job, via
582
638
  `pb.beforeSend`. Requires `pocketbase >= 0.22.0`.
583
639
 
640
+ An `expand` you add here is yours: pbtsdb strips only the paths it requested
641
+ through `alwaysFetchRelations` and `fetchRelations()`, so records expanded by
642
+ this option stay on the echoed rows, untyped.
643
+
584
644
  ### Utility Functions
585
645
 
586
646
  #### newRecordId()
@@ -611,6 +671,7 @@ pbtsdb re-exports commonly used TanStack DB utilities so you don't need to depen
611
671
  ```typescript
612
672
  import {
613
673
  // Includes helpers
674
+ materialize,
614
675
  toArray,
615
676
  createEffect,
616
677
 
@@ -671,7 +732,7 @@ export function TaskBoard({ userId }: { userId: string }) {
671
732
  ```typescript
672
733
  // ProductCatalog.tsx
673
734
  import { useLiveQuery } from '@tanstack/react-db';
674
- import { and, gte, lte } from '@tanstack/db';
735
+ import { and, eq, lte } from '@tanstack/db';
675
736
  import { useStore } from './app';
676
737
 
677
738
  export function ProductCatalog() {
@@ -683,12 +744,12 @@ export function ProductCatalog() {
683
744
  const { data: filteredProducts } = useLiveQuery((q) => {
684
745
  let query = q.from({ products })
685
746
  .where(({ products }) => and(
686
- products.in_stock === true,
747
+ eq(products.in_stock, true),
687
748
  lte(products.price, maxPrice)
688
749
  ));
689
750
 
690
751
  if (category) {
691
- query = query.where(({ products }) => products.category === category);
752
+ query = query.where(({ products }) => eq(products.category, category));
692
753
  }
693
754
 
694
755
  return query.orderBy(({ products }) => products.rating, 'desc');
@@ -718,13 +779,20 @@ export function ProductCatalog() {
718
779
  import { useLiveQuery } from '@tanstack/react-db';
719
780
  import { eq } from '@tanstack/db';
720
781
  import { useStore } from './app';
721
- import { newRecordId } from 'pbtsdb';
782
+ import { materialize, newRecordId } from 'pbtsdb';
722
783
 
723
784
  export function SocialFeed({ currentUserId }: { currentUserId: string }) {
724
- const [posts, likes] = useStore('posts', 'likes');
785
+ const [posts, likes, users] = useStore('posts', 'likes', 'users');
725
786
 
726
787
  const { data: feedPosts } = useLiveQuery((q) =>
727
- q.from({ posts }).orderBy(({ posts }) => posts.created, 'desc')
788
+ q.from({ posts })
789
+ .orderBy(({ posts }) => posts.created, 'desc')
790
+ .select(({ posts }) => ({
791
+ ...posts,
792
+ author: materialize(
793
+ q.from({ u: users }).where(({ u }) => eq(u.id, posts.author)).findOne()
794
+ ),
795
+ }))
728
796
  );
729
797
 
730
798
  const { data: userLikes } = useLiveQuery((q) =>
@@ -746,7 +814,7 @@ export function SocialFeed({ currentUserId }: { currentUserId: string }) {
746
814
  <div>
747
815
  {feedPosts?.map(post => (
748
816
  <div key={post.id}>
749
- <strong>{post.expand?.author?.username}</strong>
817
+ <strong>{post.author?.username}</strong>
750
818
  <p>{post.content}</p>
751
819
  <button onClick={() => handleLike(post.id)}>
752
820
  {likedPostIds.has(post.id) ? '❤️' : '🤍'} {post.likes_count}
@@ -828,8 +896,9 @@ export function CreateBookForm() {
828
896
 
829
897
  if (tx.state === 'completed') setTitle('');
830
898
  else setError('Failed to create book');
831
- } catch (err: any) {
832
- setError(err.data ? Object.values(err.data).join(', ') : err.message);
899
+ } catch (err) {
900
+ const failure = err as { data?: Record<string, unknown>; message?: string };
901
+ setError(failure.data ? Object.values(failure.data).join(', ') : failure.message ?? 'Failed');
833
902
  }
834
903
  };
835
904
 
@@ -893,20 +962,27 @@ TanStack DB 0.6.0 introduces **includes** — nested subqueries within `select()
893
962
 
894
963
  #### Single relation with `findOne()`
895
964
 
965
+ Wrap a `findOne()` subquery in `materialize()` to get a plain `T | undefined`
966
+ value that updates when the child changes. Without it, the include is a live
967
+ sub-collection rather than a value.
968
+
896
969
  ```typescript
897
970
  import { useLiveQuery, eq } from '@tanstack/react-db';
971
+ import { materialize } from 'pbtsdb';
898
972
 
899
973
  const { data: booksWithAuthors } = useLiveQuery((q) =>
900
974
  q.from({ b: booksCollection }).select(({ b }) => ({
901
975
  id: b.id,
902
976
  title: b.title,
903
- author: q
904
- .from({ a: authorsCollection })
905
- .where(({ a }) => eq(a.id, b.author))
906
- .select(({ a }) => ({ id: a.id, name: a.name }))
907
- .findOne(),
977
+ author: materialize(
978
+ q.from({ a: authorsCollection })
979
+ .where(({ a }) => eq(a.id, b.author))
980
+ .select(({ a }) => ({ id: a.id, name: a.name }))
981
+ .findOne()
982
+ ),
908
983
  }))
909
984
  );
985
+ // booksWithAuthors[0].author?.name
910
986
  ```
911
987
 
912
988
  #### Many relation with `toArray()`
@@ -929,27 +1005,31 @@ const { data: booksWithTags } = useLiveQuery((q) =>
929
1005
  );
930
1006
  ```
931
1007
 
932
- #### Combining expand with includes
1008
+ #### Includes on filed relations
933
1009
 
934
- Use PocketBase's `expand` to auto-populate a related collection, then use includes to query from it:
1010
+ Use PocketBase's `expand` to file a relation into its target collection, then use includes to query from it. Because the rows are already in the store, the include makes no request:
935
1011
 
936
1012
  ```typescript
937
1013
  const authorsCollection = c('authors', { syncMode: 'on-demand' });
1014
+ const tagsCollection = c('tags', { syncMode: 'on-demand' });
1015
+ const bookTagsCollection = c('book_tags', { syncMode: 'on-demand' });
938
1016
  const booksCollection = c('books', {
939
1017
  syncMode: 'on-demand',
940
- relations: { author: authorsCollection }, // Auto-populates authorsCollection
941
- alwaysExpand: ['author'],
1018
+ relations: { author: authorsCollection }, // where expanded authors are filed
1019
+ alwaysFetchRelations: ['author'],
942
1020
  });
943
1021
 
944
1022
  const { data } = useLiveQuery((q) =>
945
1023
  q.from({ b: booksCollection }).select(({ b }) => ({
946
1024
  id: b.id,
947
1025
  title: b.title,
948
- // Query from the expand-populated authorsCollection
949
- author: q.from({ a: authorsCollection })
950
- .where(({ a }) => eq(a.id, b.author))
951
- .select(({ a }) => ({ id: a.id, name: a.name }))
952
- .findOne(),
1026
+ // Reads from authorsCollection with no request; the rows are already filed
1027
+ author: materialize(
1028
+ q.from({ a: authorsCollection })
1029
+ .where(({ a }) => eq(a.id, b.author))
1030
+ .select(({ a }) => ({ id: a.id, name: a.name }))
1031
+ .findOne()
1032
+ ),
953
1033
  tags: toArray(
954
1034
  q.from({ bt: bookTagsCollection })
955
1035
  .where(({ bt }) => eq(bt.book, b.id))
@@ -992,11 +1072,11 @@ const books = c('books', {
992
1072
  omitOnInsert: ['created', 'updated'] as const
993
1073
  });
994
1074
 
995
- // ✅ Good - with always-expanded relations
1075
+ // ✅ Good - with always-fetched relations
996
1076
  const authors = c('authors', {});
997
1077
  const books = c('books', {
998
1078
  relations: { author: authors },
999
- alwaysExpand: ['author'],
1079
+ alwaysFetchRelations: ['author'],
1000
1080
  });
1001
1081
  ```
1002
1082
 
@@ -1019,7 +1099,7 @@ export const { Provider, useStore } = createReactProvider({
1019
1099
 
1020
1100
  ### 2. Create Dependencies Before Dependents
1021
1101
 
1022
- When using expand collections, create the target collection first:
1102
+ When declaring `relations`, create the target collection first:
1023
1103
 
1024
1104
  ```typescript
1025
1105
  // ✅ Good - authors exists before books references it
@@ -1027,7 +1107,7 @@ const c = createCollection<MySchema>(pb, queryClient);
1027
1107
  const authors = c('authors', {});
1028
1108
  const books = c('books', {
1029
1109
  relations: { author: authors }, // authors is already created
1030
- alwaysExpand: ['author'],
1110
+ alwaysFetchRelations: ['author'],
1031
1111
  });
1032
1112
 
1033
1113
  // ❌ Bad - can't reference what doesn't exist yet
@@ -1035,7 +1115,7 @@ const books = c('books', {
1035
1115
  relations: {
1036
1116
  author: ??? // Where is authors?
1037
1117
  },
1038
- alwaysExpand: ['author'],
1118
+ alwaysFetchRelations: ['author'],
1039
1119
  });
1040
1120
  ```
1041
1121
 
@@ -1068,25 +1148,28 @@ if (!data?.length) return <div>No posts found</div>;
1068
1148
  return <PostsList posts={data} />;
1069
1149
  ```
1070
1150
 
1071
- ### 5. Use Expand for Performance
1151
+ ### 5. Choose Between alwaysFetchRelations and Joins
1072
1152
 
1073
- Use PocketBase's expand feature for better performance:
1153
+ `alwaysFetchRelations` costs one request but carries the related record once
1154
+ per parent row, on every fetch of the parent:
1074
1155
 
1075
1156
  ```typescript
1076
- // ✅ Fast - single query with server-side expand
1077
1157
  const c = createCollection<MySchema>(pb, queryClient);
1078
1158
  const authors = c('authors', {});
1079
1159
  const posts = c('posts', {
1080
1160
  relations: { author: authors },
1081
- alwaysExpand: ['author'], // Auto-expand on every fetch
1161
+ alwaysFetchRelations: ['author'], // expanded on every posts request
1082
1162
  });
1083
1163
 
1084
1164
  const { data } = useLiveQuery((q) => q.from({ posts }));
1085
-
1086
- // ⚠️ Slower - multiple queries + client-side join
1087
- // Only use TanStack DB joins for inner/right/full join behavior
1088
1165
  ```
1089
1166
 
1167
+ A join or a `materialize()` include costs one batched request per query
1168
+ (fetching each distinct related row once, however many parent rows reference
1169
+ it) and, once the rows are filed, subsequent queries make no request at all.
1170
+ Prefer `alwaysFetchRelations` when the parent is the only path by which those
1171
+ rows enter an on-demand collection; otherwise let the query load them.
1172
+
1090
1173
  ### 6. Configure QueryClient Defaults
1091
1174
 
1092
1175
  ```typescript
@@ -1165,12 +1248,13 @@ Contributions welcome! Please open an issue or PR.
1165
1248
  ### Development Setup
1166
1249
 
1167
1250
  **Prerequisites:**
1168
- - Node.js 18+
1251
+ - Node.js 20+
1169
1252
  - Git
1253
+ - The [PocketBase](https://pocketbase.io/docs/) binary on your `PATH` (the test server uses it)
1170
1254
 
1171
1255
  **Clone and Install:**
1172
1256
  ```bash
1173
- git clone https://github.com/yourusername/pbtsdb
1257
+ git clone https://github.com/nathanstitt/pbtsdb
1174
1258
  cd pbtsdb
1175
1259
  npm install
1176
1260
  ```