Skip to content

WIP improve schema support #401

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

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
44 changes: 34 additions & 10 deletions packages/db/src/local-only.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
DeleteMutationFnParams,
InsertMutationFnParams,
OperationType,
ResolveInsertInput,
ResolveType,
SyncConfig,
UpdateMutationFnParams,
Expand All @@ -26,7 +27,7 @@ import type { StandardSchemaV1 } from "@standard-schema/spec"
* You should provide EITHER an explicit type OR a schema, but not both, as they would conflict.
*/
export interface LocalOnlyCollectionConfig<
TExplicit = unknown,
TExplicit extends object = Record<string, unknown>,
TSchema extends StandardSchemaV1 = never,
TFallback extends Record<string, unknown> = Record<string, unknown>,
TKey extends string | number = string | number,
Expand All @@ -51,7 +52,7 @@ export interface LocalOnlyCollectionConfig<
*/
onInsert?: (
params: InsertMutationFnParams<
ResolveType<TExplicit, TSchema, TFallback>,
ResolveInsertInput<TExplicit, TSchema, TFallback>,
TKey,
LocalOnlyCollectionUtils
>
Expand Down Expand Up @@ -136,33 +137,56 @@ export interface LocalOnlyCollectionUtils extends UtilsRecord {}
* )
*/
export function localOnlyCollectionOptions<
TExplicit = unknown,
TExplicit extends object = Record<string, unknown>,
TSchema extends StandardSchemaV1 = never,
TFallback extends Record<string, unknown> = Record<string, unknown>,
TKey extends string | number = string | number,
>(
config: LocalOnlyCollectionConfig<TExplicit, TSchema, TFallback, TKey>
): CollectionConfig<ResolveType<TExplicit, TSchema, TFallback>, TKey> & {
): CollectionConfig<
ResolveType<TExplicit, TSchema, TFallback>,
TKey,
TSchema,
ResolveInsertInput<TExplicit, TSchema, TFallback>
> & {
utils: LocalOnlyCollectionUtils
} {
type ResolvedType = ResolveType<TExplicit, TSchema, TFallback>
type TItem = ResolveType<TExplicit, TSchema, TFallback>
type TInsertInput = ResolveInsertInput<TExplicit, TSchema, TFallback>

const { initialData, onInsert, onUpdate, onDelete, ...restConfig } = config

// Create the sync configuration with transaction confirmation capability
const syncResult = createLocalOnlySync<ResolvedType, TKey>(initialData)
const syncResult = createLocalOnlySync<TItem, TKey>(
initialData as Array<TItem> | undefined
)

/**
* Create wrapper handlers that call user handlers first, then confirm transactions
* Wraps the user's onInsert handler to also confirm the transaction immediately
*/
const wrappedOnInsert = async (
params: InsertMutationFnParams<ResolvedType, TKey, LocalOnlyCollectionUtils>
params: InsertMutationFnParams<TItem, TKey, LocalOnlyCollectionUtils>
) => {
// Call user handler first if provided
let handlerResult
if (onInsert) {
handlerResult = (await onInsert(params)) ?? {}
handlerResult =
(await (
onInsert as (
p: InsertMutationFnParams<
TInsertInput,
TKey,
LocalOnlyCollectionUtils
>
) => Promise<any>
)(
params as unknown as InsertMutationFnParams<
TInsertInput,
TKey,
LocalOnlyCollectionUtils
>
)) ?? {}
}

// Then synchronously confirm the transaction by looping through mutations
Expand All @@ -175,7 +199,7 @@ export function localOnlyCollectionOptions<
* Wrapper for onUpdate handler that also confirms the transaction immediately
*/
const wrappedOnUpdate = async (
params: UpdateMutationFnParams<ResolvedType, TKey, LocalOnlyCollectionUtils>
params: UpdateMutationFnParams<TItem, TKey, LocalOnlyCollectionUtils>
) => {
// Call user handler first if provided
let handlerResult
Expand All @@ -193,7 +217,7 @@ export function localOnlyCollectionOptions<
* Wrapper for onDelete handler that also confirms the transaction immediately
*/
const wrappedOnDelete = async (
params: DeleteMutationFnParams<ResolvedType, TKey, LocalOnlyCollectionUtils>
params: DeleteMutationFnParams<TItem, TKey, LocalOnlyCollectionUtils>
) => {
// Call user handler first if provided
let handlerResult
Expand Down
116 changes: 75 additions & 41 deletions packages/db/src/local-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import {
import type {
CollectionConfig,
DeleteMutationFnParams,
InsertMutationFn,
InsertMutationFnParams,
ResolveInsertInput,
ResolveType,
SyncConfig,
UpdateMutationFnParams,
Expand Down Expand Up @@ -62,6 +64,7 @@ export interface LocalStorageCollectionConfig<
TExplicit = unknown,
TSchema extends StandardSchemaV1 = never,
TFallback extends object = Record<string, unknown>,
TKey extends string | number = string | number,
> {
/**
* The key to use for storing the collection data in localStorage/sessionStorage
Expand All @@ -85,16 +88,29 @@ export interface LocalStorageCollectionConfig<
*/
id?: string
schema?: TSchema
getKey: CollectionConfig<ResolveType<TExplicit, TSchema, TFallback>>[`getKey`]
sync?: CollectionConfig<ResolveType<TExplicit, TSchema, TFallback>>[`sync`]
getKey: CollectionConfig<
ResolveType<TExplicit, TSchema, TFallback>,
TKey,
TSchema,
ResolveInsertInput<TExplicit, TSchema, TFallback>
>[`getKey`]
sync?: CollectionConfig<
ResolveType<TExplicit, TSchema, TFallback>,
TKey,
TSchema,
ResolveInsertInput<TExplicit, TSchema, TFallback>
>[`sync`]

/**
* Optional asynchronous handler function called before an insert operation
* @param params Object containing transaction and collection information
* @returns Promise resolving to any value
*/
onInsert?: (
params: InsertMutationFnParams<ResolveType<TExplicit, TSchema, TFallback>>
params: InsertMutationFnParams<
ResolveInsertInput<TExplicit, TSchema, TFallback>,
TKey
>
) => Promise<any>

/**
Expand All @@ -103,7 +119,10 @@ export interface LocalStorageCollectionConfig<
* @returns Promise resolving to any value
*/
onUpdate?: (
params: UpdateMutationFnParams<ResolveType<TExplicit, TSchema, TFallback>>
params: UpdateMutationFnParams<
ResolveType<TExplicit, TSchema, TFallback>,
TKey
>
) => Promise<any>

/**
Expand All @@ -112,7 +131,10 @@ export interface LocalStorageCollectionConfig<
* @returns Promise resolving to any value
*/
onDelete?: (
params: DeleteMutationFnParams<ResolveType<TExplicit, TSchema, TFallback>>
params: DeleteMutationFnParams<
ResolveType<TExplicit, TSchema, TFallback>,
TKey
>
) => Promise<any>
}

Expand Down Expand Up @@ -206,13 +228,23 @@ export function localStorageCollectionOptions<
TExplicit = unknown,
TSchema extends StandardSchemaV1 = never,
TFallback extends object = Record<string, unknown>,
TKey extends string | number = string | number,
>(
config: LocalStorageCollectionConfig<TExplicit, TSchema, TFallback>
): Omit<CollectionConfig<ResolveType<TExplicit, TSchema, TFallback>>, `id`> & {
config: LocalStorageCollectionConfig<TExplicit, TSchema, TFallback, TKey>
): Omit<
CollectionConfig<
ResolveType<TExplicit, TSchema, TFallback>,
TKey,
TSchema,
ResolveInsertInput<TExplicit, TSchema, TFallback>
>,
`id`
> & {
id: string
utils: LocalStorageCollectionUtils
} {
type ResolvedType = ResolveType<TExplicit, TSchema, TFallback>
type TItem = ResolveType<TExplicit, TSchema, TFallback>
type TInsertInput = ResolveInsertInput<TExplicit, TSchema, TFallback>

// Validate required parameters
if (!config.storageKey) {
Expand All @@ -237,14 +269,14 @@ export function localStorageCollectionOptions<
}

// Track the last known state to detect changes
const lastKnownData = new Map<string | number, StoredItem<ResolvedType>>()
const lastKnownData = new Map<string | number, StoredItem<TItem>>()

// Create the sync configuration
const sync = createLocalStorageSync<ResolvedType>(
const sync = createLocalStorageSync<TItem, TKey>(
config.storageKey,
storage,
storageEventApi,
config.getKey,
config.getKey as (item: TItem) => TKey,
lastKnownData
)

Expand All @@ -263,11 +295,11 @@ export function localStorageCollectionOptions<
* @param dataMap - Map of items with version tracking to save to storage
*/
const saveToStorage = (
dataMap: Map<string | number, StoredItem<ResolvedType>>
dataMap: Map<string | number, StoredItem<TItem>>
): void => {
try {
// Convert Map to object format for storage
const objectData: Record<string, StoredItem<ResolvedType>> = {}
const objectData: Record<string, StoredItem<TItem>> = {}
dataMap.forEach((storedItem, key) => {
objectData[String(key)] = storedItem
})
Expand Down Expand Up @@ -303,7 +335,7 @@ export function localStorageCollectionOptions<
* Wraps the user's onInsert handler to also save changes to localStorage
*/
const wrappedOnInsert = async (
params: InsertMutationFnParams<ResolvedType>
params: InsertMutationFnParams<TItem, TKey>
) => {
// Validate that all values in the transaction can be JSON serialized
params.transaction.mutations.forEach((mutation) => {
Expand All @@ -313,20 +345,20 @@ export function localStorageCollectionOptions<
// Call the user handler BEFORE persisting changes (if provided)
let handlerResult: any = {}
if (config.onInsert) {
handlerResult = (await config.onInsert(params)) ?? {}
handlerResult =
(await (config.onInsert as InsertMutationFn<TInsertInput, TKey>)(
params as unknown as InsertMutationFnParams<TInsertInput, TKey>
)) ?? {}
}

// Always persist to storage
// Load current data from storage
const currentData = loadFromStorage<ResolvedType>(
config.storageKey,
storage
)
const currentData = loadFromStorage<TItem>(config.storageKey, storage)

// Add new items with version keys
params.transaction.mutations.forEach((mutation) => {
const key = config.getKey(mutation.modified)
const storedItem: StoredItem<ResolvedType> = {
const key = (config.getKey as (item: TItem) => TKey)(mutation.modified)
const storedItem: StoredItem<TItem> = {
versionKey: generateUuid(),
data: mutation.modified,
}
Expand All @@ -343,7 +375,7 @@ export function localStorageCollectionOptions<
}

const wrappedOnUpdate = async (
params: UpdateMutationFnParams<ResolvedType>
params: UpdateMutationFnParams<TItem, TKey>
) => {
// Validate that all values in the transaction can be JSON serialized
params.transaction.mutations.forEach((mutation) => {
Expand All @@ -358,15 +390,12 @@ export function localStorageCollectionOptions<

// Always persist to storage
// Load current data from storage
const currentData = loadFromStorage<ResolvedType>(
config.storageKey,
storage
)
const currentData = loadFromStorage<TItem>(config.storageKey, storage)

// Update items with new version keys
params.transaction.mutations.forEach((mutation) => {
const key = config.getKey(mutation.modified)
const storedItem: StoredItem<ResolvedType> = {
const key = (config.getKey as (item: TItem) => TKey)(mutation.modified)
const storedItem: StoredItem<TItem> = {
versionKey: generateUuid(),
data: mutation.modified,
}
Expand All @@ -383,7 +412,7 @@ export function localStorageCollectionOptions<
}

const wrappedOnDelete = async (
params: DeleteMutationFnParams<ResolvedType>
params: DeleteMutationFnParams<TItem, TKey>
) => {
// Call the user handler BEFORE persisting changes (if provided)
let handlerResult: any = {}
Expand All @@ -393,15 +422,14 @@ export function localStorageCollectionOptions<

// Always persist to storage
// Load current data from storage
const currentData = loadFromStorage<ResolvedType>(
config.storageKey,
storage
)
const currentData = loadFromStorage<TItem>(config.storageKey, storage)

// Remove items
params.transaction.mutations.forEach((mutation) => {
// For delete operations, mutation.original contains the full object
const key = config.getKey(mutation.original as ResolvedType)
const key = (config.getKey as (item: TItem) => TKey)(
mutation.original as TItem
)
currentData.delete(key)
})

Expand Down Expand Up @@ -433,7 +461,10 @@ export function localStorageCollectionOptions<
...restConfig,
id: collectionId,
sync,
onInsert: wrappedOnInsert,
onInsert: wrappedOnInsert as unknown as InsertMutationFn<
TInsertInput,
TKey
>,
onUpdate: wrappedOnUpdate,
onDelete: wrappedOnDelete,
utils: {
Expand Down Expand Up @@ -506,14 +537,17 @@ function loadFromStorage<T extends object>(
* @param lastKnownData - Map tracking the last known state for change detection
* @returns Sync configuration with manual trigger capability
*/
function createLocalStorageSync<T extends object>(
function createLocalStorageSync<
T extends object,
TKey extends string | number = string | number,
>(
storageKey: string,
storage: StorageApi,
storageEventApi: StorageEventApi,
_getKey: (item: T) => string | number,
_getKey: (item: T) => TKey,
lastKnownData: Map<string | number, StoredItem<T>>
): SyncConfig<T> & { manualTrigger?: () => void } {
let syncParams: Parameters<SyncConfig<T>[`sync`]>[0] | null = null
): SyncConfig<T, TKey> & { manualTrigger?: () => void } {
let syncParams: Parameters<SyncConfig<T, TKey>[`sync`]>[0] | null = null

/**
* Compare two Maps to find differences using version keys
Expand Down Expand Up @@ -588,8 +622,8 @@ function createLocalStorageSync<T extends object>(
}
}

const syncConfig: SyncConfig<T> & { manualTrigger?: () => void } = {
sync: (params: Parameters<SyncConfig<T>[`sync`]>[0]) => {
const syncConfig: SyncConfig<T, TKey> & { manualTrigger?: () => void } = {
sync: (params: Parameters<SyncConfig<T, TKey>[`sync`]>[0]) => {
const { begin, write, commit, markReady } = params

// Store sync params for later use
Expand Down
Loading