Skip to content

Commit d88192d

Browse files
Ensure schemas can apply defaults when inserting (#209)
Co-authored-by: Kyle Mathews <[email protected]>
1 parent 16a0a1a commit d88192d

File tree

8 files changed

+281
-61
lines changed

8 files changed

+281
-61
lines changed

.changeset/pink-badgers-wink.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@tanstack/db-collections": patch
3+
"@tanstack/db": patch
4+
---
5+
6+
Ensure schemas can apply defaults when inserting

packages/db/src/collection.ts

Lines changed: 62 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,17 @@ import type {
1212
OperationConfig,
1313
OptimisticChangeMessage,
1414
PendingMutation,
15+
ResolveInsertInput,
1516
ResolveType,
1617
StandardSchema,
1718
Transaction as TransactionType,
19+
TransactionWithMutations,
1820
UtilsRecord,
1921
} from "./types"
2022
import type { StandardSchemaV1 } from "@standard-schema/spec"
2123

2224
// Store collections in memory
23-
export const collectionsStore = new Map<string, CollectionImpl<any, any>>()
25+
export const collectionsStore = new Map<string, CollectionImpl<any, any, any>>()
2426

2527
interface PendingSyncedTransaction<T extends object = Record<string, unknown>> {
2628
committed: boolean
@@ -32,12 +34,15 @@ interface PendingSyncedTransaction<T extends object = Record<string, unknown>> {
3234
* @template T - The type of items in the collection
3335
* @template TKey - The type of the key for the collection
3436
* @template TUtils - The utilities record type
37+
* @template TInsertInput - The type for insert operations (can be different from T for schemas with defaults)
3538
*/
3639
export interface Collection<
3740
T extends object = Record<string, unknown>,
3841
TKey extends string | number = string | number,
3942
TUtils extends UtilsRecord = {},
40-
> extends CollectionImpl<T, TKey> {
43+
TSchema extends StandardSchemaV1 = StandardSchemaV1,
44+
TInsertInput extends object = T,
45+
> extends CollectionImpl<T, TKey, TUtils, TSchema, TInsertInput> {
4146
readonly utils: TUtils
4247
}
4348

@@ -124,12 +129,22 @@ export function createCollection<
124129
options: CollectionConfig<
125130
ResolveType<TExplicit, TSchema, TFallback>,
126131
TKey,
127-
TSchema
132+
TSchema,
133+
ResolveInsertInput<TExplicit, TSchema, TFallback>
128134
> & { utils?: TUtils }
129-
): Collection<ResolveType<TExplicit, TSchema, TFallback>, TKey, TUtils> {
135+
): Collection<
136+
ResolveType<TExplicit, TSchema, TFallback>,
137+
TKey,
138+
TUtils,
139+
TSchema,
140+
ResolveInsertInput<TExplicit, TSchema, TFallback>
141+
> {
130142
const collection = new CollectionImpl<
131143
ResolveType<TExplicit, TSchema, TFallback>,
132-
TKey
144+
TKey,
145+
TUtils,
146+
TSchema,
147+
ResolveInsertInput<TExplicit, TSchema, TFallback>
133148
>(options)
134149

135150
// Copy utils to both top level and .utils namespace
@@ -142,7 +157,9 @@ export function createCollection<
142157
return collection as Collection<
143158
ResolveType<TExplicit, TSchema, TFallback>,
144159
TKey,
145-
TUtils
160+
TUtils,
161+
TSchema,
162+
ResolveInsertInput<TExplicit, TSchema, TFallback>
146163
>
147164
}
148165

@@ -179,8 +196,10 @@ export class CollectionImpl<
179196
T extends object = Record<string, unknown>,
180197
TKey extends string | number = string | number,
181198
TUtils extends UtilsRecord = {},
199+
TSchema extends StandardSchemaV1 = StandardSchemaV1,
200+
TInsertInput extends object = T,
182201
> {
183-
public config: CollectionConfig<T, TKey, any>
202+
public config: CollectionConfig<T, TKey, TSchema, TInsertInput>
184203

185204
// Core state - make public for testing
186205
public transactions: SortedMap<string, Transaction<any>>
@@ -312,7 +331,7 @@ export class CollectionImpl<
312331
* @param config - Configuration object for the collection
313332
* @throws Error if sync config is missing
314333
*/
315-
constructor(config: CollectionConfig<T, TKey, any>) {
334+
constructor(config: CollectionConfig<T, TKey, TSchema, TInsertInput>) {
316335
// eslint-disable-next-line
317336
if (!config) {
318337
throw new Error(`Collection requires a config`)
@@ -1322,9 +1341,11 @@ export class CollectionImpl<
13221341
* console.log('Insert failed:', error)
13231342
* }
13241343
*/
1325-
insert = (data: T | Array<T>, config?: InsertConfig) => {
1344+
insert = (
1345+
data: TInsertInput | Array<TInsertInput>,
1346+
config?: InsertConfig
1347+
) => {
13261348
this.validateCollectionUsable(`insert`)
1327-
13281349
const ambientTransaction = getActiveTransaction()
13291350

13301351
// If no ambient transaction exists, check for an onInsert handler early
@@ -1335,15 +1356,15 @@ export class CollectionImpl<
13351356
}
13361357

13371358
const items = Array.isArray(data) ? data : [data]
1338-
const mutations: Array<PendingMutation<T, `insert`>> = []
1359+
const mutations: Array<PendingMutation<T>> = []
13391360

13401361
// Create mutations for each item
13411362
items.forEach((item) => {
13421363
// Validate the data against the schema if one exists
13431364
const validatedData = this.validateData(item, `insert`)
13441365

13451366
// Check if an item with this ID already exists in the collection
1346-
const key = this.getKeyFromItem(item)
1367+
const key = this.getKeyFromItem(validatedData)
13471368
if (this.has(key)) {
13481369
throw `Cannot insert document with ID "${key}" because it already exists in the collection`
13491370
}
@@ -1353,7 +1374,15 @@ export class CollectionImpl<
13531374
mutationId: crypto.randomUUID(),
13541375
original: {},
13551376
modified: validatedData,
1356-
changes: validatedData,
1377+
// Pick the values from validatedData based on what's passed in - this is for cases
1378+
// where a schema has default values. The validated data has the extra default
1379+
// values but for changes, we just want to show the data that was actually passed in.
1380+
changes: Object.fromEntries(
1381+
Object.keys(item).map((k) => [
1382+
k,
1383+
validatedData[k as keyof typeof validatedData],
1384+
])
1385+
) as TInsertInput,
13571386
globalKey,
13581387
key,
13591388
metadata: config?.metadata as unknown,
@@ -1381,8 +1410,12 @@ export class CollectionImpl<
13811410
const directOpTransaction = createTransaction<T>({
13821411
mutationFn: async (params) => {
13831412
// Call the onInsert handler with the transaction and collection
1384-
return this.config.onInsert!({
1385-
...params,
1413+
return await this.config.onInsert!({
1414+
transaction:
1415+
params.transaction as unknown as TransactionWithMutations<
1416+
TInsertInput,
1417+
`insert`
1418+
>,
13861419
collection: this as unknown as Collection<T, TKey, TUtils>,
13871420
})
13881421
},
@@ -1526,7 +1559,7 @@ export class CollectionImpl<
15261559
}
15271560

15281561
// Create mutations for each object that has changes
1529-
const mutations: Array<PendingMutation<T, `update`>> = keysArray
1562+
const mutations: Array<PendingMutation<T, `update`, this>> = keysArray
15301563
.map((key, index) => {
15311564
const itemChanges = changesArray[index] // User-provided changes for this specific item
15321565

@@ -1581,7 +1614,7 @@ export class CollectionImpl<
15811614
collection: this,
15821615
}
15831616
})
1584-
.filter(Boolean) as Array<PendingMutation<T, `update`>>
1617+
.filter(Boolean) as Array<PendingMutation<T, `update`, this>>
15851618

15861619
// If no changes were made, return an empty transaction early
15871620
if (mutations.length === 0) {
@@ -1609,7 +1642,11 @@ export class CollectionImpl<
16091642
mutationFn: async (params) => {
16101643
// Call the onUpdate handler with the transaction and collection
16111644
return this.config.onUpdate!({
1612-
...params,
1645+
transaction:
1646+
params.transaction as unknown as TransactionWithMutations<
1647+
T,
1648+
`update`
1649+
>,
16131650
collection: this as unknown as Collection<T, TKey, TUtils>,
16141651
})
16151652
},
@@ -1677,7 +1714,7 @@ export class CollectionImpl<
16771714
}
16781715

16791716
const keysArray = Array.isArray(keys) ? keys : [keys]
1680-
const mutations: Array<PendingMutation<T, `delete`>> = []
1717+
const mutations: Array<PendingMutation<T, `delete`, this>> = []
16811718

16821719
for (const key of keysArray) {
16831720
if (!this.has(key)) {
@@ -1686,7 +1723,7 @@ export class CollectionImpl<
16861723
)
16871724
}
16881725
const globalKey = this.generateGlobalKey(key, this.get(key)!)
1689-
const mutation: PendingMutation<T, `delete`> = {
1726+
const mutation: PendingMutation<T, `delete`, this> = {
16901727
mutationId: crypto.randomUUID(),
16911728
original: this.get(key)!,
16921729
modified: this.get(key)!,
@@ -1724,7 +1761,11 @@ export class CollectionImpl<
17241761
mutationFn: async (params) => {
17251762
// Call the onDelete handler with the transaction and collection
17261763
return this.config.onDelete!({
1727-
...params,
1764+
transaction:
1765+
params.transaction as unknown as TransactionWithMutations<
1766+
T,
1767+
`delete`
1768+
>,
17281769
collection: this as unknown as Collection<T, TKey, TUtils>,
17291770
})
17301771
},

packages/db/src/local-storage.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -393,7 +393,7 @@ export function localStorageCollectionOptions<
393393
// Remove items
394394
params.transaction.mutations.forEach((mutation) => {
395395
// For delete operations, mutation.original contains the full object
396-
const key = config.getKey(mutation.original)
396+
const key = config.getKey(mutation.original as ResolvedType)
397397
currentData.delete(key)
398398
})
399399

@@ -506,7 +506,7 @@ function createLocalStorageSync<T extends object>(
506506
storageKey: string,
507507
storage: StorageApi,
508508
storageEventApi: StorageEventApi,
509-
getKey: (item: T) => string | number,
509+
_getKey: (item: T) => string | number,
510510
lastKnownData: Map<string | number, StoredItem<T>>
511511
): SyncConfig<T> & { manualTrigger?: () => void } {
512512
let syncParams: Parameters<SyncConfig<T>[`sync`]>[0] | null = null

packages/db/src/transactions.ts

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { createDeferred } from "./deferred"
22
import type { Deferred } from "./deferred"
33
import type {
44
MutationFn,
5-
OperationType,
65
PendingMutation,
76
TransactionConfig,
87
TransactionState,
@@ -66,10 +65,10 @@ let sequenceNumber = 0
6665
* // Commit later
6766
* await tx.commit()
6867
*/
69-
export function createTransaction<
70-
TData extends object = Record<string, unknown>,
71-
>(config: TransactionConfig<TData>): Transaction<TData> {
72-
const newTransaction = new Transaction<TData>(config)
68+
export function createTransaction<T extends object = Record<string, unknown>>(
69+
config: TransactionConfig<T>
70+
): Transaction<T> {
71+
const newTransaction = new Transaction<T>(config)
7372
transactions.push(newTransaction)
7473
return newTransaction
7574
}
@@ -108,15 +107,12 @@ function removeFromPendingList(tx: Transaction<any>) {
108107
}
109108
}
110109

111-
class Transaction<
112-
T extends object = Record<string, unknown>,
113-
TOperation extends OperationType = OperationType,
114-
> {
110+
class Transaction<T extends object = Record<string, unknown>> {
115111
public id: string
116112
public state: TransactionState
117113
public mutationFn: MutationFn<T>
118-
public mutations: Array<PendingMutation<T, TOperation>>
119-
public isPersisted: Deferred<Transaction<T, TOperation>>
114+
public mutations: Array<PendingMutation<T>>
115+
public isPersisted: Deferred<Transaction<T>>
120116
public autoCommit: boolean
121117
public createdAt: Date
122118
public sequenceNumber: number
@@ -134,7 +130,7 @@ class Transaction<
134130
this.mutationFn = config.mutationFn
135131
this.state = `pending`
136132
this.mutations = []
137-
this.isPersisted = createDeferred<Transaction<T, TOperation>>()
133+
this.isPersisted = createDeferred<Transaction<T>>()
138134
this.autoCommit = config.autoCommit ?? true
139135
this.createdAt = new Date()
140136
this.sequenceNumber = sequenceNumber++

0 commit comments

Comments
 (0)