Skip to content

Ensure schemas can apply defaults when inserting #209

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 27 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
8a1a9d7
test: add schema defaults tests, including type tests
DawidWraga Jun 25, 2025
9557c2d
fix transaction is no longer pending test error
DawidWraga Jun 25, 2025
cc927f3
fix: test not finding right item consistently
DawidWraga Jun 25, 2025
3889de7
fix: use validated schema to ensure defaults are passed on insert
DawidWraga Jun 25, 2025
c1c1034
fix: change type test so that it only uses schema, no generic
DawidWraga Jun 26, 2025
05e0262
initial input type fix
DawidWraga Jun 26, 2025
5e42c12
refactor
DawidWraga Jun 26, 2025
bcb397b
refactor: clean up types
DawidWraga Jun 26, 2025
8ca9a19
Merge branch 'main' into zod-schema-defaults
KyleAMathews Jun 26, 2025
5a5e63b
fix: support explicit types input
DawidWraga Jun 26, 2025
639d2e2
Merge branch 'zod-schema-defaults' of https://github.com/DawidWraga/d…
DawidWraga Jun 26, 2025
6021e15
Merge branch 'zod-schema-defaults' of https://github.com/DawidWraga/d…
DawidWraga Jun 26, 2025
3eb962c
Merge remote-tracking branch 'origin/main' into zod-schema-defaults
KyleAMathews Jun 26, 2025
e361299
make types more permissve in the query-builder
KyleAMathews Jun 26, 2025
e269683
Changes should reflect what's actually inserted
KyleAMathews Jun 26, 2025
c605486
fix type
KyleAMathews Jun 26, 2025
1256eb7
Fix?
KyleAMathews Jun 26, 2025
64deab6
add changeset
KyleAMathews Jun 26, 2025
cd45995
Merge remote-tracking branch 'origin/main' into zod-schema-defaults
KyleAMathews Jul 7, 2025
c34a406
refactor: simplify generics
DawidWraga Jul 7, 2025
2e86707
remove redundant
DawidWraga Jul 7, 2025
2716dfb
make collection config fullly type safe (remove any) and fix mutatio…
DawidWraga Jul 7, 2025
8ba9f4a
remove redundant
DawidWraga Jul 7, 2025
bd75b80
fix tests
DawidWraga Jul 7, 2025
99ee013
checkpoint
DawidWraga Jul 8, 2025
da9c316
checkpoint - add transaction types
DawidWraga Jul 8, 2025
921fec4
attempt to simplify types with `ResolveTransactionData` inside `creat…
DawidWraga Jul 8, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/pink-badgers-wink.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@tanstack/db-collections": patch
"@tanstack/db": patch
---

Ensure schemas can apply defaults when inserting
1 change: 1 addition & 0 deletions packages/db-collections/src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ export function queryCollectionOptions<
if (!queryClient) {
throw new Error(`[QueryCollection] queryClient must be provided.`)
}

// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!getKey) {
throw new Error(`[QueryCollection] getKey must be provided.`)
Expand Down
15 changes: 9 additions & 6 deletions packages/db-collections/tests/electric.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
TransactionWithMutations,
} from "@tanstack/db"
import type { Message, Row } from "@electric-sql/client"
import type { StandardSchemaV1 } from "@standard-schema/spec"

// Mock the ShapeStream module
const mockSubscribe = vi.fn()
Expand All @@ -26,7 +27,13 @@ vi.mock(`@electric-sql/client`, async () => {
})

describe(`Electric Integration`, () => {
let collection: Collection<Row, string | number, ElectricCollectionUtils>
let collection: Collection<
Row,
string | number,
ElectricCollectionUtils,
StandardSchemaV1<unknown, unknown>,
Row
>
let subscriber: (messages: Array<Message<Row>>) => void

beforeEach(() => {
Expand Down Expand Up @@ -55,11 +62,7 @@ describe(`Electric Integration`, () => {
const options = electricCollectionOptions(config)

// Create collection with Electric configuration using the new utility exposure pattern
collection = createCollection<
Row,
string | number,
ElectricCollectionUtils
>(options)
collection = createCollection(options)
})

it(`should handle incoming insert messages and commit on up-to-date`, () => {
Expand Down
166 changes: 100 additions & 66 deletions packages/db/src/collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ import type {
CollectionStatus,
Fn,
InsertConfig,
InsertMutationFnParams,
OperationConfig,
OptimisticChangeMessage,
PendingMutation,
ResolveInsertInput,
ResolveType,
StandardSchema,
Transaction as TransactionType,
Expand All @@ -20,7 +22,7 @@ import type {
import type { StandardSchemaV1 } from "@standard-schema/spec"

// Store collections in memory
export const collectionsStore = new Map<string, CollectionImpl<any, any>>()
export const collectionsStore = new Map<string, CollectionImpl<any, any, any>>()

interface PendingSyncedTransaction<T extends object = Record<string, unknown>> {
committed: boolean
Expand All @@ -32,12 +34,15 @@ interface PendingSyncedTransaction<T extends object = Record<string, unknown>> {
* @template T - The type of items in the collection
* @template TKey - The type of the key for the collection
* @template TUtils - The utilities record type
* @template TInsertInput - The type for insert operations (can be different from T for schemas with defaults)
*/
export interface Collection<
T extends object = Record<string, unknown>,
TKey extends string | number = string | number,
TUtils extends UtilsRecord = {},
> extends CollectionImpl<T, TKey> {
TSchema extends StandardSchemaV1 = StandardSchemaV1,
TInsertInput extends object = T,
> extends CollectionImpl<T, TKey, TSchema, TInsertInput> {
readonly utils: TUtils
}

Expand Down Expand Up @@ -84,12 +89,21 @@ export function createCollection<
options: CollectionConfig<
ResolveType<TExplicit, TSchema, TFallback>,
TKey,
TSchema
TSchema,
ResolveInsertInput<TExplicit, TSchema, TFallback>
> & { utils?: TUtils }
): Collection<ResolveType<TExplicit, TSchema, TFallback>, TKey, TUtils> {
): Collection<
ResolveType<TExplicit, TSchema, TFallback>,
TKey,
TUtils,
TSchema,
ResolveInsertInput<TExplicit, TSchema, TFallback>
> {
const collection = new CollectionImpl<
ResolveType<TExplicit, TSchema, TFallback>,
TKey
TKey,
TSchema,
ResolveInsertInput<TExplicit, TSchema, TFallback>
>(options)

// Copy utils to both top level and .utils namespace
Expand All @@ -102,7 +116,9 @@ export function createCollection<
return collection as Collection<
ResolveType<TExplicit, TSchema, TFallback>,
TKey,
TUtils
TUtils,
TSchema,
ResolveInsertInput<TExplicit, TSchema, TFallback>
>
}

Expand Down Expand Up @@ -138,8 +154,10 @@ export class SchemaValidationError extends Error {
export class CollectionImpl<
T extends object = Record<string, unknown>,
TKey extends string | number = string | number,
TSchema extends StandardSchemaV1 = StandardSchemaV1,
TInsertInput extends object = T,
> {
public config: CollectionConfig<T, TKey, any>
public config: CollectionConfig<T, TKey, TSchema, TInsertInput>

// Core state - make public for testing
public transactions: SortedMap<string, Transaction<any>>
Expand Down Expand Up @@ -265,7 +283,7 @@ export class CollectionImpl<
* @param config - Configuration object for the collection
* @throws Error if sync config is missing
*/
constructor(config: CollectionConfig<T, TKey, any>) {
constructor(config: CollectionConfig<T, TKey, TSchema, TInsertInput>) {
// eslint-disable-next-line
if (!config) {
throw new Error(`Collection requires a config`)
Expand Down Expand Up @@ -1254,9 +1272,11 @@ export class CollectionImpl<
* // Insert with custom key
* insert({ text: "Buy groceries" }, { key: "grocery-task" })
*/
insert = (data: T | Array<T>, config?: InsertConfig) => {
insert = (
data: TInsertInput | Array<TInsertInput>,
config?: InsertConfig
) => {
this.validateCollectionUsable(`insert`)

const ambientTransaction = getActiveTransaction()

// If no ambient transaction exists, check for an onInsert handler early
Expand All @@ -1267,25 +1287,33 @@ export class CollectionImpl<
}

const items = Array.isArray(data) ? data : [data]
const mutations: Array<PendingMutation<T, `insert`>> = []
const mutations: Array<PendingMutation<T, `insert`, TInsertInput>> = []

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

// Check if an item with this ID already exists in the collection
const key = this.getKeyFromItem(item)
const key = this.getKeyFromItem(validatedData)
if (this.has(key)) {
throw `Cannot insert document with ID "${key}" because it already exists in the collection`
}
const globalKey = this.generateGlobalKey(key, item)

const mutation: PendingMutation<T, `insert`> = {
const mutation: PendingMutation<T, any, any, any> = {
mutationId: crypto.randomUUID(),
original: {},
modified: validatedData,
changes: validatedData,
// Pick the values from validatedData based on what's passed in - this is for cases
// where a schema has default values. The validated data has the extra default
// values but for changes, we just want to show the data that was actually passed in.
changes: Object.fromEntries(
Object.keys(item).map((k) => [
k,
validatedData[k as keyof typeof validatedData],
])
) as TInsertInput,
globalKey,
key,
metadata: config?.metadata as unknown,
Expand All @@ -1312,7 +1340,9 @@ export class CollectionImpl<
const directOpTransaction = createTransaction<T>({
mutationFn: async (params) => {
// Call the onInsert handler with the transaction
return this.config.onInsert!(params)
return this.config.onInsert!(
params as unknown as InsertMutationFnParams<T>
)
},
})

Expand Down Expand Up @@ -1453,61 +1483,64 @@ export class CollectionImpl<
}

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

// Skip items with no changes
if (!itemChanges || Object.keys(itemChanges).length === 0) {
return null
}
const mutations: Array<PendingMutation<T, `update`, TInsertInput, this>> =
keysArray
.map((key, index) => {
const itemChanges = changesArray[index] // User-provided changes for this specific item

// Skip items with no changes
if (!itemChanges || Object.keys(itemChanges).length === 0) {
return null
}

const originalItem = currentObjects[index] as unknown as T
// Validate the user-provided changes for this item
const validatedUpdatePayload = this.validateData(
itemChanges,
`update`,
key
)
const originalItem = currentObjects[index] as unknown as T
// Validate the user-provided changes for this item
const validatedUpdatePayload = this.validateData(
itemChanges,
`update`,
key
)

// Construct the full modified item by applying the validated update payload to the original item
const modifiedItem = Object.assign(
{},
originalItem,
validatedUpdatePayload
)
// Construct the full modified item by applying the validated update payload to the original item
const modifiedItem = Object.assign(
{},
originalItem,
validatedUpdatePayload
)

// Check if the ID of the item is being changed
const originalItemId = this.getKeyFromItem(originalItem)
const modifiedItemId = this.getKeyFromItem(modifiedItem)
// Check if the ID of the item is being changed
const originalItemId = this.getKeyFromItem(originalItem)
const modifiedItemId = this.getKeyFromItem(modifiedItem)

if (originalItemId !== modifiedItemId) {
throw new Error(
`Updating the key of an item is not allowed. Original key: "${originalItemId}", Attempted new key: "${modifiedItemId}". Please delete the old item and create a new one if a key change is necessary.`
)
}
if (originalItemId !== modifiedItemId) {
throw new Error(
`Updating the key of an item is not allowed. Original key: "${originalItemId}", Attempted new key: "${modifiedItemId}". Please delete the old item and create a new one if a key change is necessary.`
)
}

const globalKey = this.generateGlobalKey(modifiedItemId, modifiedItem)
const globalKey = this.generateGlobalKey(modifiedItemId, modifiedItem)

return {
mutationId: crypto.randomUUID(),
original: originalItem,
modified: modifiedItem,
changes: validatedUpdatePayload as Partial<T>,
globalKey,
key,
metadata: config.metadata as unknown,
syncMetadata: (this.syncedMetadata.get(key) || {}) as Record<
string,
unknown
>,
type: `update`,
createdAt: new Date(),
updatedAt: new Date(),
collection: this,
}
})
.filter(Boolean) as Array<PendingMutation<T, `update`>>
return {
mutationId: crypto.randomUUID(),
original: originalItem,
modified: modifiedItem,
changes: validatedUpdatePayload as Partial<T>,
globalKey,
key,
metadata: config.metadata as unknown,
syncMetadata: (this.syncedMetadata.get(key) || {}) as Record<
string,
unknown
>,
type: `update`,
createdAt: new Date(),
updatedAt: new Date(),
collection: this,
}
})
.filter(Boolean) as Array<
PendingMutation<T, `update`, TInsertInput, this>
>

// If no changes were made, return an empty transaction early
if (mutations.length === 0) {
Expand Down Expand Up @@ -1585,7 +1618,8 @@ export class CollectionImpl<
}

const keysArray = Array.isArray(keys) ? keys : [keys]
const mutations: Array<PendingMutation<T, `delete`>> = []
const mutations: Array<PendingMutation<T, `delete`, TInsertInput, this>> =
[]

for (const key of keysArray) {
if (!this.has(key)) {
Expand All @@ -1594,7 +1628,7 @@ export class CollectionImpl<
)
}
const globalKey = this.generateGlobalKey(key, this.get(key)!)
const mutation: PendingMutation<T, `delete`> = {
const mutation: PendingMutation<T, `delete`, TInsertInput, this> = {
mutationId: crypto.randomUUID(),
original: this.get(key)!,
modified: this.get(key)!,
Expand Down
Loading
Loading