···11+import {
22+ openDB,
33+ type DBSchema,
44+ type IDBPDatabase,
55+ type IDBPTransaction,
66+ type IndexNames,
77+ type StoreNames,
88+} from 'idb'
99+import {IndexBuilder, type IndexDef} from './schema-index'
1010+1111+type StringKeys<S> = keyof S & string
1212+1313+// ─────────────────────────────────────────────────────────────────────────────
1414+// Store Definition & Builder
1515+// ─────────────────────────────────────────────────────────────────────────────
1616+1717+/**
1818+ * Indexes phantom type: a record from index name to its key paths tuple
1919+ * e.g. { idx_feedId_readAt: readonly ['feedId', 'readAt'], idx_multi_tags_tag: readonly ['...tags.tag'] }
2020+ */
2121+type IndexesConstraint = Record<string, readonly string[]>
2222+2323+export interface StoreDef<
2424+ Name extends string,
2525+ Doc,
2626+ Row = Doc,
2727+ Indexes extends IndexesConstraint = IndexesConstraint,
2828+> {
2929+ name: Name
3030+ pkey: StringKeys<Row> | StringKeys<Row>[]
3131+ indexes: IndexDef<Row, string, readonly string[]>[]
3232+ encode?: (value: Doc) => Row
3333+ decode?: (row: Row) => Doc
3434+3535+ // phantom types for extraction
3636+ __row: Row
3737+ __doc: Doc
3838+ __indexes: Indexes
3939+}
4040+4141+/** Extract name from an IndexBuilder or IndexDef */
4242+type ExtractIndexName<T> =
4343+ T extends IndexBuilder<any, infer N, any> ? N : T extends IndexDef<any, infer N, any> ? N : never
4444+4545+/** Extract keys from an IndexBuilder or IndexDef */
4646+type ExtractIndexKeys<T> =
4747+ T extends IndexBuilder<any, any, infer K> ? K : T extends IndexDef<any, any, infer K> ? K : never
4848+4949+/** Build a record from a single index builder/def */
5050+type IndexToRecord<T> = {
5151+ [K in ExtractIndexName<T>]: ExtractIndexKeys<T>
5252+}
5353+5454+/** Recursively merge index records from a tuple */
5555+type MergeIndexRecords<T extends readonly unknown[]> = T extends readonly [infer First, ...infer Rest]
5656+ ? IndexToRecord<First> & MergeIndexRecords<Rest>
5757+ : {}
5858+5959+/** Convert an array of index builders to a record */
6060+type IndexArrayToRecord<T extends readonly unknown[]> =
6161+ MergeIndexRecords<T> extends infer R ? {[K in keyof R]: R[K]} : never
6262+6363+/** Base type for IndexBuilder/IndexDef that allows any keys (used in constraints) */
6464+type AnyIndexBuilder<Row> = IndexBuilder<Row, string, readonly string[]>
6565+type AnyIndexDef<Row> = IndexDef<Row, string, readonly string[]>
6666+6767+export class StoreBuilder<Name extends string, Doc, Row = Doc, Indexes extends IndexesConstraint = {}> {
6868+ readonly name: Name
6969+7070+ declare __doc: Doc
7171+ declare __row: Row
7272+ declare __indexes: Indexes
7373+7474+ #pkey: string | string[]
7575+ #indexes?: AnyIndexDef<Row>[]
7676+ #encoder?: (value: Doc) => Row
7777+ #decoder?: (row: Row) => Doc
7878+7979+ constructor(name: Name, pkey: string | string[]) {
8080+ this.name = name
8181+ this.#pkey = pkey
8282+ }
8383+8484+ codec<R>(fns: {encode: (value: Doc) => R; decode: (row: R) => Doc}): StoreBuilder<Name, Doc, R, {}> {
8585+ this.#encoder = fns.encode as any
8686+ this.#decoder = fns.decode as any
8787+ return this as unknown as StoreBuilder<Name, Doc, R, {}>
8888+ }
8989+9090+ indexes<const T extends readonly (AnyIndexDef<Row> | AnyIndexBuilder<Row>)[]>(
9191+ cb: (maker: <N extends string = string>(name?: N) => IndexBuilder<Row, N, readonly []>) => T,
9292+ ): StoreBuilder<Name, Doc, Row, IndexArrayToRecord<T>> {
9393+ const defs = cb(<N extends string = string>(name?: N) => new IndexBuilder<Row, N, readonly []>(name)).map(
9494+ (v) => (v instanceof IndexBuilder ? v.build() : v) as AnyIndexDef<Row>,
9595+ )
9696+ this.#indexes = defs
9797+ return this as unknown as StoreBuilder<Name, Doc, Row, IndexArrayToRecord<T>>
9898+ }
9999+100100+ build(): StoreDef<Name, Doc, Row, Indexes> {
101101+ return {
102102+ name: this.name,
103103+ pkey: this.#pkey as StringKeys<Row> | StringKeys<Row>[],
104104+ indexes: this.#indexes ?? [],
105105+ encode: this.#encoder,
106106+ decode: this.#decoder,
107107+ __doc: undefined as unknown as Doc,
108108+ __row: undefined as unknown as Row,
109109+ __indexes: undefined as unknown as Indexes,
110110+ }
111111+ }
112112+}
113113+114114+// ─────────────────────────────────────────────────────────────────────────────
115115+// Database Definition
116116+// ─────────────────────────────────────────────────────────────────────────────
117117+118118+type StoresConstraint = Record<string, StoreDef<string, any, any>>
119119+120120+type SeedFn<Stores extends StoresConstraint> = (
121121+ tx: IDBPTransaction<ToDBSchema<Stores>, StoreNames<ToDBSchema<Stores>>[], 'versionchange'>,
122122+) => void
123123+124124+type MigrationFn<Stores extends StoresConstraint> = (
125125+ tx: IDBPTransaction<ToDBSchema<Stores>, StoreNames<ToDBSchema<Stores>>[], 'versionchange'>,
126126+ oldVersion: number,
127127+ newVersion: number | null,
128128+) => void
129129+130130+export interface DatabaseDef<Stores extends StoresConstraint> {
131131+ name: string
132132+ stores: Stores
133133+ seedFn?: SeedFn<Stores>
134134+ migrations: MigrationFn<Stores>[]
135135+}
136136+137137+// ─────────────────────────────────────────────────────────────────────────────
138138+// Path Resolution - convert index paths to their value types
139139+// ─────────────────────────────────────────────────────────────────────────────
140140+141141+/** Resolve a dot-path like "foo.bar" into its value type */
142142+type ResolvePath<T, Path extends string> = Path extends `${infer First}.${infer Rest}`
143143+ ? First extends keyof T
144144+ ? ResolvePath<NonNullable<T[First]>, Rest>
145145+ : never
146146+ : Path extends keyof T
147147+ ? T[Path]
148148+ : never
149149+150150+/**
151151+ * Resolve a spread path like "...tags.tag" where tags is Tag[] and we want Tag['tag']
152152+ * Path format: "...arrayProp.nestedPath"
153153+ */
154154+type ResolveSpreadPath<Row, Path extends string> = Path extends `...${infer Rest}`
155155+ ? Rest extends `${infer ArrayProp}.${infer NestedPath}`
156156+ ? ArrayProp extends keyof Row
157157+ ? NonNullable<Row[ArrayProp]> extends Array<infer El>
158158+ ? ResolvePath<El, NestedPath>
159159+ : never
160160+ : never
161161+ : Rest extends keyof Row
162162+ ? NonNullable<Row[Rest]> extends Array<infer El>
163163+ ? El
164164+ : never
165165+ : never
166166+ : ResolvePath<Row, Path>
167167+168168+/** Convert a single index key path to its runtime type (excludes undefined since it's not a valid IDBValidKey) */
169169+type PathToKeyType<Row, Path extends string> = NonNullable<
170170+ Path extends `...${string}` ? ResolveSpreadPath<Row, Path> : ResolvePath<Row, Path>
171171+>
172172+173173+/** Convert a tuple of paths to a tuple of key types (preserves tuple structure) */
174174+type PathsToKeyTypes<Row, Paths extends readonly string[]> = Paths extends readonly [
175175+ infer First extends string,
176176+ ...infer Rest extends readonly string[],
177177+]
178178+ ? [PathToKeyType<Row, First>, ...PathsToKeyTypes<Row, Rest>]
179179+ : []
180180+181181+/** Convert our __indexes phantom type to idb's indexes format */
182182+type IndexesToDBIndexes<Row, Indexes extends IndexesConstraint> = {
183183+ [Name in keyof Indexes]: Indexes[Name] extends readonly string[]
184184+ ? PathsToKeyTypes<Row, Indexes[Name]>
185185+ : never
186186+}
187187+188188+// Convert our StoreDef types to idb's DBSchema format
189189+type ToDBSchema<Stores extends StoresConstraint> = {
190190+ [K in keyof Stores & string]: {
191191+ key: string
192192+ value: Stores[K]['__row']
193193+ indexes: IndexesToDBIndexes<Stores[K]['__row'], Stores[K]['__indexes']>
194194+ }
195195+}
196196+197197+// The live database handle returned by open()
198198+export interface Database<Stores extends StoresConstraint> {
199199+ name: string
200200+ stores: Stores
201201+ idb: IDBPDatabase<ToDBSchema<Stores>>
202202+ close(): void
203203+}
204204+205205+// ─────────────────────────────────────────────────────────────────────────────
206206+// Database Builder
207207+// ─────────────────────────────────────────────────────────────────────────────
208208+209209+export class DatabaseBuilder<Stores extends StoresConstraint = {}> {
210210+ readonly #name: string
211211+ readonly #storesList: StoreDef<string, any, any>[]
212212+ #seedFn?: SeedFn<Stores>
213213+ #migrations: MigrationFn<Stores>[] = []
214214+215215+ constructor(name: string, stores: StoreDef<string, any, any>[] = []) {
216216+ this.#name = name
217217+ this.#storesList = stores
218218+ }
219219+220220+ /**
221221+ * Add a store to the database.
222222+ * Usage: db.store<DocType>()('storeName', 'primaryKey', s => s.codec(...).indexes(...))
223223+ */
224224+ store<Doc>(): <Name extends string, Row = Doc, Indexes extends IndexesConstraint = {}>(
225225+ name: Name,
226226+ pkey: StringKeys<Doc> | StringKeys<Doc>[],
227227+ configure?: (builder: StoreBuilder<Name, Doc, Doc, {}>) => StoreBuilder<Name, Doc, Row, Indexes>,
228228+ ) => DatabaseBuilder<Stores & {[K in Name]: StoreDef<Name, Doc, Row, Indexes>}> {
229229+ return <Name extends string, Row = Doc, Indexes extends IndexesConstraint = {}>(
230230+ name: Name,
231231+ pkey: StringKeys<Doc> | StringKeys<Doc>[],
232232+ configure?: (builder: StoreBuilder<Name, Doc, Doc, {}>) => StoreBuilder<Name, Doc, Row, Indexes>,
233233+ ) => {
234234+ const builder = new StoreBuilder<Name, Doc, Doc, {}>(name, pkey)
235235+ const configured = configure ? configure(builder) : builder
236236+ const storeDef = configured.build()
237237+238238+ this.#storesList.push(storeDef)
239239+ return this as unknown as DatabaseBuilder<Stores & {[K in Name]: StoreDef<Name, Doc, Row, Indexes>}>
240240+ }
241241+ }
242242+243243+ seed(fn: SeedFn<Stores>): this {
244244+ this.#seedFn = fn
245245+ return this
246246+ }
247247+248248+ migrate(fn: MigrationFn<Stores>): this {
249249+ this.#migrations.push(fn)
250250+ return this
251251+ }
252252+253253+ build(): DatabaseDef<Stores> {
254254+ const stores: Record<string, StoreDef<string, any, any>> = {}
255255+ for (const def of this.#storesList) {
256256+ stores[def.name] = def
257257+ }
258258+ return {
259259+ name: this.#name,
260260+ stores: stores as Stores,
261261+ seedFn: this.#seedFn,
262262+ migrations: this.#migrations,
263263+ }
264264+ }
265265+}
266266+267267+// ─────────────────────────────────────────────────────────────────────────────
268268+// Public API
269269+// ─────────────────────────────────────────────────────────────────────────────
270270+271271+/**
272272+ * Define a database schema.
273273+ * Returns a DatabaseDef that can be passed to open().
274274+ */
275275+export function define<Stores extends StoresConstraint>(
276276+ name: string,
277277+ cb: (db: DatabaseBuilder<{}>) => DatabaseBuilder<Stores>,
278278+): DatabaseDef<Stores> {
279279+ const builder = new DatabaseBuilder<{}>(name)
280280+ const configured = cb(builder)
281281+ return configured.build()
282282+}
283283+284284+export interface MetaSchema extends DBSchema {
285285+ status: {
286286+ key: 'status'
287287+ value: {
288288+ version: number
289289+ applied: Date
290290+ schema: unknown
291291+ }
292292+ }
293293+}
294294+295295+/**
296296+ * Open a database from a definition.
297297+ */
298298+export async function open<Stores extends StoresConstraint>(
299299+ def: DatabaseDef<Stores>,
300300+): Promise<Database<Stores>> {
301301+ const metadb = await openDB<MetaSchema>(`${def.name}-meta`, 1, {
302302+ upgrade(db, ov) {
303303+ console.log('upgrading metadb')
304304+ if (ov < 1) {
305305+ const store = db.createObjectStore('status')
306306+ store.put({version: 0, schema: undefined, applied: new Date()}, 'status')
307307+ }
308308+ },
309309+ })
310310+311311+ console.log('upgrade metadb complete')
312312+ const current = await metadb.get('status', 'status')
313313+ if (!current) {
314314+ throw new Error('metadb upgrade never ran?')
315315+ }
316316+317317+ let version = current.version
318318+ const schema = def.stores
319319+ if (version < 1 || current.schema != schema) {
320320+ version = version + 1
321321+ }
322322+323323+ console.log('opening db', def, version)
324324+ const storedb = await openDB<ToDBSchema<Stores>>(def.name, version, {
325325+ upgrade(db, ov, nv, tx) {
326326+ try {
327327+ console.log('upgrading db', ov, nv)
328328+329329+ // Create object stores if this is a fresh database
330330+ if (ov < 1) {
331331+ for (const key in def.stores) {
332332+ const store = def.stores[key]
333333+ db.createObjectStore(store.name as StoreNames<ToDBSchema<Stores>>, {keyPath: store.pkey})
334334+ }
335335+ }
336336+337337+ // Create indexes (checking if they already exist)
338338+ for (const key in def.stores) {
339339+ const store = def.stores[key]
340340+ const table = tx.objectStore(store.name as StoreNames<ToDBSchema<Stores>>)
341341+ for (const idx of store.indexes) {
342342+ const idxname = idx.name as IndexNames<ToDBSchema<Stores>, StoreNames<ToDBSchema<Stores>>>
343343+ if (table.indexNames.contains(idxname)) {
344344+ continue // TODO: diff and rebuild
345345+ }
346346+347347+ // TODO: this is wrong, should be getting index keys from row?
348348+ // strip "..." prefix from spread keys for IndexedDB
349349+ const keyPaths = idx.keys.map((k) => k.replace('...', 'multi_').replace('.', '_'))
350350+ console.log('creating index', idx.name, 'on', store.name, 'with keyPath', keyPaths)
351351+ table.createIndex(idxname, keyPaths)
352352+ }
353353+ }
354354+355355+ if (ov < 1) {
356356+ console.log('running seed')
357357+ def.seedFn?.(tx)
358358+ console.log('seed complete')
359359+ }
360360+361361+ console.log('running migrations from', ov, 'count:', def.migrations.slice(ov).length)
362362+ def.migrations.slice(ov).forEach((mig, i) => {
363363+ console.log('running migration', i + ov)
364364+ mig(tx, ov, nv)
365365+ console.log('migration', i + ov, 'complete')
366366+ })
367367+ console.log('upgrade complete')
368368+ } catch (e) {
369369+ console.error('error in upgrade', e)
370370+ throw e
371371+ }
372372+ },
373373+ })
374374+375375+ console.log('we have database version:', storedb)
376376+377377+ // TODO: implement the open logic
378378+ // 1. Probe current state
379379+ // 2. Compare schemas
380380+ // 3. Open with version bump if needed
381381+ // 4. Apply schema diff in onupgradeneeded
382382+ // 5. Run seed (if fresh) or migrations (if upgrade)
383383+ // 6. Save schema to _meta
384384+385385+ return {
386386+ name: def.name,
387387+ stores: def.stores,
388388+ idb: null as any, // TODO: actual idb handle
389389+ close: () => {},
390390+ }
391391+}
392392+393393+// ─────────────────────────────────────────────────────────────────────────────
394394+// Type Tests
395395+// ─────────────────────────────────────────────────────────────────────────────
396396+397397+type Feed = {
398398+ id: string
399399+ title: string
400400+ tags: {tag: string; value: string}[]
401401+}
402402+403403+type Entry = {
404404+ id: string
405405+ readAt?: Date
406406+ feedId: string
407407+ tags: {tag: string; value: string}[]
408408+}
409409+410410+const myDb = define('mydb', (db) =>
411411+ db
412412+ .store<Entry>()('entries', ['id', 'feedId'], (s) =>
413413+ s
414414+ .codec({
415415+ encode: (e) => ({...e, readAt: e.readAt?.getTime()}),
416416+ decode: (r) => ({...r, readAt: r.readAt ? new Date(r.readAt) : undefined}),
417417+ })
418418+ .indexes((idx) => [idx().on('feedId', 'readAt'), idx().on('...tags.tag')]),
419419+ )
420420+ .store<Feed>()('feeds', 'id', (s) => s.indexes((idx) => [idx().on('...tags.tag')]))
421421+ .seed((_tx) => {})
422422+ .migrate((_tx) => {}),
423423+)
424424+425425+// Test type extraction
426426+type _TestStores = typeof myDb.stores
427427+type _TestEntries = _TestStores['entries']
428428+type _TestFeeds = _TestStores['feeds']
429429+type _TestEntryDoc = _TestEntries['__row']
430430+type _TestFeedDoc = _TestFeeds['__doc']
431431+432432+// Should be EntryRow (with number) - will error if wrong
433433+const _e: _TestEntryDoc = {id: '1', feedId: 'f1', readAt: undefined, tags: []}
434434+void _e
435435+// Should be Feed - will error if wrong
436436+const _f: _TestFeedDoc = {id: '1', title: 'hi', tags: []}
437437+void _f
438438+439439+// Negative test - should error if types are correct
440440+// @ts-expect-error - Entry doesn't have 'title'
441441+const _e2: _TestEntryDoc = {id: '1', title: 'wrong', tags: []}
442442+// @ts-expect-error - Feed doesn't have 'feedId'
443443+const _f2: _TestFeedDoc = {id: '1', feedId: 'wrong', tags: []}
444444+445445+// Test open returns correct type
446446+void async function _testOpen() {
447447+ const db = await open(myDb)
448448+ // db.stores should have entries and feeds
449449+ const _entries: typeof db.stores.entries = db.stores.entries
450450+ void _entries
451451+ const _feeds: typeof db.stores.feeds = db.stores.feeds
452452+ void _feeds
453453+}
454454+455455+// Test __indexes phantom type extraction
456456+type _TestEntryIndexes = _TestEntries['__indexes']
457457+type _TestFeedIndexes = _TestFeeds['__indexes']
458458+459459+// Verify the index types are captured correctly
460460+// Entry should have: { idx_feedId_readAt: readonly ['feedId', 'readAt'], idx_multi_tags_tag: readonly ['...tags.tag'] }
461461+const _assertEntryIndex1: _TestEntryIndexes['idx_feedId_readAt'] = ['feedId', 'readAt'] as const
462462+const _assertEntryIndex2: _TestEntryIndexes['idx_multi_tags_tag'] = ['...tags.tag'] as const
463463+464464+// Feed should have: { idx_multi_tags_tag: readonly ['...tags.tag'] }
465465+const _assertFeedIndex: _TestFeedIndexes['idx_multi_tags_tag'] = ['...tags.tag'] as const
466466+void [_assertEntryIndex1, _assertEntryIndex2, _assertFeedIndex]
467467+468468+// Negative tests - these should error if types are correct
469469+// @ts-expect-error - wrong key order
470470+const _wrongOrder: _TestEntryIndexes['idx_feedId_readAt'] = ['readAt', 'feedId'] as const
471471+// @ts-expect-error - wrong key name
472472+const _wrongKey: _TestEntryIndexes['idx_feedId_readAt'] = ['feedId', 'wrongKey'] as const
473473+474474+// Test DBSchema derivation - verify index key types are resolved
475475+type _TestDBSchema = ToDBSchema<typeof myDb.stores>
476476+type _TestEntriesDBSchema = _TestDBSchema['entries']
477477+type _TestEntriesIndexes = _TestEntriesDBSchema['indexes']
478478+479479+// Direct test of PathsToKeyTypes with literal tuple
480480+type _DebugRow = _TestEntries['__row']
481481+type _DirectTest = PathsToKeyTypes<_DebugRow, readonly ['feedId', 'readAt']>
482482+483483+// idx_feedId_readAt should resolve to [string, number | undefined]
484484+// idx_multi_tags_tag should resolve to [string]
485485+// But the actual types depend on whether the tuple literal is preserved through IndexesToDBIndexes
486486+487487+// Use the direct test which should work - verifies path resolution logic is correct
488488+const _dbIndexKey1: _DirectTest = ['f1', 123]
489489+void _dbIndexKey1
490490+491491+// Test that we can at least get the index names from the derived schema
492492+type _IndexNames = keyof _TestEntriesIndexes // should be 'idx_feedId_readAt' | 'idx_multi_tags_tag'
493493+const _indexName1: _IndexNames = 'idx_feedId_readAt'
494494+const _indexName2: _IndexNames = 'idx_multi_tags_tag'
495495+void [_indexName1, _indexName2]
496496+497497+// Now test the full resolved key types from the DBSchema
498498+type _ResolvedIdx1 = _TestEntriesIndexes['idx_feedId_readAt'] // should be [string, number | undefined]
499499+type _ResolvedIdx2 = _TestEntriesIndexes['idx_multi_tags_tag'] // should be [string]
500500+501501+// These should work if the types are correctly resolved
502502+const _resolvedKey1: _ResolvedIdx1 = ['feed123', 12345]
503503+const _resolvedKey2: _ResolvedIdx2 = ['mytag']
504504+void [_resolvedKey1, _resolvedKey2]
505505+506506+// @ts-expect-error - wrong types should fail
507507+const _badResolvedKey1: _ResolvedIdx1 = [123, 'wrong']
508508+// @ts-expect-error - wrong tuple length
509509+const _badResolvedKey2: _ResolvedIdx2 = ['tag', 'extra']
510510+511511+// Verify resolved types are tuples not unions
512512+// _ResolvedIdx1 should be [string, number | undefined], so [0] is string, [1] is number | undefined
513513+const _resolvedIdx1_0: _ResolvedIdx1[0] = 'test' // should be string
514514+const _resolvedIdx1_1: _ResolvedIdx1[1] = 123 // should be number | undefined
515515+// @ts-expect-error - [0] is string, not number
516516+const _badResolved1_0: _ResolvedIdx1[0] = 123
517517+// @ts-expect-error - [1] is number | undefined, not string
518518+const _badResolved1_1: _ResolvedIdx1[1] = 'wrong'
519519+void [_resolvedIdx1_0, _resolvedIdx1_1, _badResolved1_0, _badResolved1_1]
520520+521521+// Debug: check actual structure of _TestEntryIndexes
522522+// If keys are tuples, these should work; if union, they'll be different
523523+const _debugIdx1: _TestEntryIndexes['idx_feedId_readAt'] = ['feedId', 'readAt']
524524+const _debugIdx1_0: _TestEntryIndexes['idx_feedId_readAt'][0] = 'feedId'
525525+const _debugIdx1_1: _TestEntryIndexes['idx_feedId_readAt'][1] = 'readAt'
526526+void [_debugIdx1, _debugIdx1_0, _debugIdx1_1]
527527+528528+// This should fail if it's a proper tuple (not a union array)
529529+// @ts-expect-error - if tuple, index 0 should only be 'feedId', not 'readAt'
530530+const _debugBad: _TestEntryIndexes['idx_feedId_readAt'][0] = 'readAt'
531531+void _debugBad
532532+533533+// Debug: what are the actual index names?
534534+type _DebugIndexNames = keyof _TestEntryIndexes
535535+const _debugName1: _DebugIndexNames = 'idx_feedId_readAt'
536536+// @ts-expect-error - should fail if names are literal
537537+const _debugBadName: _DebugIndexNames = 'wrong_name'
538538+void [_debugName1, _debugBadName]
539539+540540+// Debug: check DBSchema index names
541541+type _DebugDBIndexNames = keyof _TestEntriesIndexes
542542+const _debugDBName1: _DebugDBIndexNames = 'idx_feedId_readAt'
543543+// @ts-expect-error - should fail if names are literal
544544+const _debugBadDBName: _DebugDBIndexNames = 'wrong_db_name'
545545+void [_debugDBName1, _debugBadDBName]
+111
src/lib/idbase/schema-index.ts
···11+import type {IndexPath, ReplicablePath} from './schema'
22+33+/** Replace all dots with underscores in a string */
44+type ReplaceDotsWithUnderscores<S extends string> = S extends `${infer Head}.${infer Tail}`
55+ ? `${Head}_${ReplaceDotsWithUnderscores<Tail>}`
66+ : S
77+88+/** Convert a key path to its index name segment (replace ... with multi_, and . with _) */
99+type KeyToNameSegment<K extends string> = K extends `...${infer Rest}`
1010+ ? `multi_${ReplaceDotsWithUnderscores<Rest>}`
1111+ : ReplaceDotsWithUnderscores<K>
1212+1313+/** Join segments with underscore */
1414+type JoinWithUnderscore<T extends readonly string[]> = T extends readonly [infer First extends string]
1515+ ? KeyToNameSegment<First>
1616+ : T extends readonly [infer First extends string, ...infer Rest extends readonly string[]]
1717+ ? `${KeyToNameSegment<First>}_${JoinWithUnderscore<Rest>}`
1818+ : ''
1919+2020+/** Generate auto index name from keys */
2121+type AutoIndexName<Keys extends readonly string[]> = `idx_${JoinWithUnderscore<Keys>}`
2222+2323+export interface IndexDef<
2424+ Row,
2525+ Name extends string = string,
2626+ Keys extends readonly string[] = readonly IndexPath<Row>[],
2727+> {
2828+ name: Name
2929+ keys: Keys
3030+ replicate: ReplicablePath<Row>[]
3131+ roots: Set<string>
3232+}
3333+3434+//
3535+3636+export class IndexBuilder<Proto, Name extends string = string, Keys extends readonly string[] = readonly []> {
3737+ #name?: Name
3838+ #keys?: Keys
3939+ #replicate?: ReplicablePath<Proto>[]
4040+4141+ // Phantom types to extract Name and Keys
4242+ declare __name: Name
4343+ declare __keys: Keys
4444+4545+ constructor(name?: Name) {
4646+ this.#name = name
4747+ }
4848+4949+ /**
5050+ * Set the index keys. If no name was provided, the Name type will be derived from the keys.
5151+ */
5252+ on<K extends readonly IndexPath<Proto>[]>(
5353+ ...keys: K
5454+ ): IndexBuilder<Proto, Name extends string ? (string extends Name ? AutoIndexName<K> : Name) : Name, K> {
5555+ this.#keys = keys as unknown as Keys
5656+ return this as unknown as IndexBuilder<
5757+ Proto,
5858+ Name extends string ? (string extends Name ? AutoIndexName<K> : Name) : Name,
5959+ K
6060+ >
6161+ }
6262+6363+ replicate(...keys: ReplicablePath<Proto>[]): this {
6464+ this.#replicate = keys
6565+ return this
6666+ }
6767+6868+ #explodeRoot(key: string) {
6969+ const spread = key.startsWith('...')
7070+ if (spread) {
7171+ const path = key.slice(3).split('.')
7272+ return {
7373+ spread: true,
7474+ root: path[0],
7575+ path,
7676+ }
7777+ } else {
7878+ return {
7979+ spread: false,
8080+ root: null,
8181+ path: key.split('.'),
8282+ }
8383+ }
8484+ }
8585+8686+ #computeRoots(keys: readonly string[]): Set<string> {
8787+ const roots = new Set<string>()
8888+ for (const key of keys) {
8989+ const {spread, root} = this.#explodeRoot(key)
9090+ if (spread && root) {
9191+ roots.add(root)
9292+ }
9393+ }
9494+9595+ return roots
9696+ }
9797+9898+ build(): IndexDef<Proto, Name, Keys> {
9999+ if (this.#keys === undefined) throw new Error('no keys specified')
100100+101101+ const keys = this.#keys
102102+ const name = (this.#name ?? `idx_${keys.map((k) => k.replace('...', 'multi_')).join('_')}`) as Name
103103+104104+ return {
105105+ name,
106106+ keys,
107107+ replicate: this.#replicate ?? [],
108108+ roots: this.#computeRoots(keys),
109109+ }
110110+ }
111111+}
+11
src/lib/idbase/schema-mimetype.ts
···11+import {z} from 'zod/mini'
22+33+export type MimeType = z.infer<typeof mimeTypeSchema>
44+export const mimeTypeSchema = z.templateLiteral([z.string(), '/', z.string()])
55+66+export type MimeTypeOutput<F extends MimeType> =
77+ F extends `application/json`
88+ ? unknown
99+ : F extends `text/${string}`
1010+ ? string
1111+ : Uint8Array
+3
src/lib/idbase/schema-store.ts
···11+// Re-export from schema-builder for backwards compatibility
22+export type {StoreDef} from './schema-builder'
33+export {StoreBuilder} from './schema-builder'
···11+/** Replace all dots with underscores in a string */
22+export type ReplaceDotsWithUnderscores<S extends string> = S extends `${infer Head}.${infer Tail}`
33+ ? `${Head}_${ReplaceDotsWithUnderscores<Tail>}`
44+ : S
55+66+/** Convert a key path to its index name segment (replace ... with multi_, and . with _) */
77+export type KeyToNameSegment<K extends string> = K extends `...${infer Rest}`
88+ ? `multi_${ReplaceDotsWithUnderscores<Rest>}`
99+ : ReplaceDotsWithUnderscores<K>
1010+1111+/** Join segments with underscore */
1212+export type JoinWithUnderscore<T extends readonly string[]> = T extends readonly [infer First extends string]
1313+ ? KeyToNameSegment<First>
1414+ : T extends readonly [infer First extends string, ...infer Rest extends readonly string[]]
1515+ ? `${KeyToNameSegment<First>}_${JoinWithUnderscore<Rest>}`
1616+ : ''