muya 2.1.1 → 2.1.2

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.
Files changed (50) hide show
  1. package/cjs/index.js +1 -1
  2. package/esm/create-state.js +1 -1
  3. package/esm/create.js +1 -1
  4. package/esm/scheduler.js +1 -1
  5. package/esm/select.js +1 -1
  6. package/esm/sqlite/__tests__/create-sqlite-state.test.js +1 -0
  7. package/esm/sqlite/__tests__/map-deque.test.js +1 -0
  8. package/esm/sqlite/__tests__/table.test.js +1 -0
  9. package/esm/sqlite/__tests__/use-sqlite-state.test.js +1 -0
  10. package/esm/sqlite/create-sqlite-state.js +1 -0
  11. package/esm/sqlite/table/backend.js +1 -0
  12. package/esm/sqlite/table/bun-backend.js +1 -0
  13. package/esm/sqlite/table/map-deque.js +1 -0
  14. package/esm/sqlite/table/table.js +10 -0
  15. package/esm/sqlite/table/table.types.js +0 -0
  16. package/esm/sqlite/table/where.js +1 -0
  17. package/esm/sqlite/use-sqlite-value.js +1 -0
  18. package/esm/utils/common.js +1 -1
  19. package/package.json +1 -1
  20. package/src/__tests__/scheduler.test.tsx +2 -2
  21. package/src/create-state.ts +3 -2
  22. package/src/create.ts +22 -24
  23. package/src/scheduler.ts +15 -7
  24. package/src/select.ts +15 -17
  25. package/src/sqlite/__tests__/create-sqlite-state.test.ts +81 -0
  26. package/src/sqlite/__tests__/map-deque.test.ts +61 -0
  27. package/src/sqlite/__tests__/table.test.ts +142 -0
  28. package/src/sqlite/__tests__/use-sqlite-state.test.ts +213 -0
  29. package/src/sqlite/create-sqlite-state.ts +256 -0
  30. package/src/sqlite/table/backend.ts +21 -0
  31. package/src/sqlite/table/bun-backend.ts +38 -0
  32. package/src/sqlite/table/map-deque.ts +29 -0
  33. package/src/sqlite/table/table.ts +200 -0
  34. package/src/sqlite/table/table.types.ts +55 -0
  35. package/src/sqlite/table/where.ts +267 -0
  36. package/src/sqlite/use-sqlite-value.ts +76 -0
  37. package/src/types.ts +1 -0
  38. package/src/utils/common.ts +6 -2
  39. package/types/create.d.ts +3 -3
  40. package/types/scheduler.d.ts +12 -3
  41. package/types/sqlite/create-sqlite-state.d.ts +22 -0
  42. package/types/sqlite/table/backend.d.ts +20 -0
  43. package/types/sqlite/table/bun-backend.d.ts +2 -0
  44. package/types/sqlite/table/map-deque.d.ts +5 -0
  45. package/types/sqlite/table/table.d.ts +3 -0
  46. package/types/sqlite/table/table.types.d.ts +52 -0
  47. package/types/sqlite/table/where.d.ts +32 -0
  48. package/types/sqlite/use-sqlite-value.d.ts +21 -0
  49. package/types/types.d.ts +1 -0
  50. package/types/utils/common.d.ts +2 -2
@@ -0,0 +1,200 @@
1
+ // table.ts
2
+ /* eslint-disable sonarjs/different-types-comparison */
3
+ /* eslint-disable sonarjs/cognitive-complexity */
4
+ /* eslint-disable @typescript-eslint/no-shadow */
5
+ /* eslint-disable no-shadow */
6
+ import type { Table, DbOptions, DocType, Key, SearchOptions, MutationResult } from './table.types'
7
+ import { getWhereQuery, type Where } from './where'
8
+
9
+ const DELETE_IN_CHUNK = 500 // keep well below SQLite's default 999 parameter limit
10
+ export const DEFAULT_STEP_SIZE = 100
11
+ export async function createTable<Document extends DocType>(options: DbOptions<Document>): Promise<Table<Document>> {
12
+ const { backend, tableName, indexes, key } = options
13
+ const hasUserKey = key !== undefined
14
+
15
+ // Schema
16
+ if (hasUserKey) {
17
+ await backend.execute(`
18
+ CREATE TABLE IF NOT EXISTS ${tableName} (
19
+ key TEXT PRIMARY KEY,
20
+ data TEXT NOT NULL
21
+ );
22
+ `)
23
+ } else {
24
+ await backend.execute(`
25
+ CREATE TABLE IF NOT EXISTS ${tableName} (
26
+ data TEXT NOT NULL
27
+ );
28
+ `)
29
+ }
30
+
31
+ // JSON expression indexes for fields under data
32
+ for (const index of indexes ?? []) {
33
+ const idx = String(index)
34
+ await backend.execute(`CREATE INDEX IF NOT EXISTS idx_${tableName}_${idx} ON ${tableName} (json_extract(data, '$.${idx}'));`)
35
+ }
36
+
37
+ function getKeyFromDocument(document: Document): Key | undefined {
38
+ return hasUserKey ? (document[key as keyof Document] as unknown as Key | undefined) : undefined
39
+ }
40
+
41
+ async function getChanges(conn: typeof backend): Promise<number> {
42
+ const r = await conn.select<Array<{ c: number }>>(`SELECT changes() AS c`)
43
+ return r[0]?.c ?? 0
44
+ }
45
+
46
+ const table: Table<Document> = {
47
+ backend,
48
+
49
+ async set(document, backendOverride) {
50
+ const db = backendOverride ?? backend
51
+ const json = JSON.stringify(document)
52
+
53
+ if (hasUserKey) {
54
+ const id = getKeyFromDocument(document)
55
+ if (id === undefined || id === null) {
56
+ throw new Error(
57
+ `Document is missing the configured key "${String(key)}". Provide it or create the table without "key".`,
58
+ )
59
+ }
60
+
61
+ // Fast path: UPDATE first
62
+ await db.execute(`UPDATE ${tableName} SET data = ? WHERE key = ?`, [json, id])
63
+ const updated = await getChanges(db)
64
+ if (updated === 1) return { key: id, op: 'update' }
65
+
66
+ // No row updated => try INSERT
67
+ try {
68
+ await db.execute(`INSERT INTO ${tableName} (key, data) VALUES (?, ?)`, [id, json])
69
+ return { key: id, op: 'insert' }
70
+ } catch {
71
+ await db.execute(`UPDATE ${tableName} SET data = ? WHERE key = ?`, [json, id])
72
+ return { key: id, op: 'update' }
73
+ }
74
+ }
75
+
76
+ // ROWID mode
77
+ await db.execute(`INSERT INTO ${tableName} (data) VALUES (?)`, [json])
78
+ const rows = await db.select<Array<{ id: number }>>(`SELECT last_insert_rowid() AS id`)
79
+ const rowid = rows[0]?.id
80
+ if (typeof rowid !== 'number') throw new Error('Failed to retrieve last_insert_rowid()')
81
+ const result: MutationResult = { key: rowid, op: 'insert' }
82
+ return result
83
+ },
84
+
85
+ // --- FIXED: include rowid ---
86
+ async get<Selected = Document>(
87
+ keyValue: Key,
88
+ selector: (document: Document, meta: { rowid: number }) => Selected = (d, _m) => d as unknown as Selected,
89
+ ) {
90
+ const whereKey = hasUserKey ? `key = ?` : `rowid = ?`
91
+ const result = await backend.select<Array<{ data: string; rowid: number }>>(
92
+ `SELECT rowid, data FROM ${tableName} WHERE ${whereKey}`,
93
+ [keyValue],
94
+ )
95
+ if (result.length === 0) return
96
+ const [item] = result
97
+ const { data, rowid } = item
98
+ const document = JSON.parse(data) as Document
99
+ return selector(document, { rowid }) as Selected
100
+ },
101
+
102
+ async delete(keyValue: Key) {
103
+ const whereKey = hasUserKey ? `key = ?` : `rowid = ?`
104
+ await backend.execute(`DELETE FROM ${tableName} WHERE ${whereKey}`, [keyValue])
105
+ const changed = await backend.select<Array<{ c: number }>>(`SELECT changes() AS c`)
106
+ if ((changed[0]?.c ?? 0) > 0) {
107
+ return { key: keyValue, op: 'delete' }
108
+ }
109
+ return
110
+ },
111
+
112
+ // --- FIXED: include rowid in search ---
113
+ async *search<Selected = Document>(options: SearchOptions<Document, Selected> = {}): AsyncIterableIterator<Selected> {
114
+ const {
115
+ sorBy,
116
+ order = 'asc',
117
+ limit,
118
+ offset = 0,
119
+ where,
120
+ select = (document, _meta) => document as unknown as Selected,
121
+ stepSize = DEFAULT_STEP_SIZE,
122
+ } = options
123
+
124
+ let baseQuery = `SELECT rowid, data FROM ${tableName}`
125
+ if (where) baseQuery += ' ' + getWhereQuery(where)
126
+
127
+ let yielded = 0
128
+ let currentOffset = offset
129
+ while (true) {
130
+ let query = baseQuery
131
+
132
+ if (sorBy) {
133
+ query += ` ORDER BY json_extract(data, '$.${String(sorBy)}') COLLATE NOCASE ${order.toUpperCase()}`
134
+ } else {
135
+ query += hasUserKey ? ` ORDER BY key COLLATE NOCASE ${order.toUpperCase()}` : ` ORDER BY rowid ${order.toUpperCase()}`
136
+ }
137
+
138
+ const batchLimit = limit ? Math.min(stepSize, limit - yielded) : stepSize
139
+ query += ` LIMIT ${batchLimit} OFFSET ${currentOffset}`
140
+
141
+ const results = await backend.select<Array<{ rowid: number; data: string }>>(query)
142
+ if (results.length === 0) break
143
+
144
+ for (const { rowid, data } of results) {
145
+ if (limit && yielded >= limit) return
146
+ const document = JSON.parse(data) as Document
147
+ yield select(document, { rowId: rowid }) as Selected
148
+ yielded++
149
+ }
150
+
151
+ if (results.length < batchLimit || (limit && yielded >= limit)) break
152
+ currentOffset += results.length
153
+ }
154
+ },
155
+
156
+ async count(options: { where?: Where<Document> } = {}) {
157
+ const { where } = options
158
+ let query = `SELECT COUNT(*) as count FROM ${tableName}`
159
+ if (where) query += ' ' + getWhereQuery(where)
160
+ const result = await backend.select<Array<{ count: number }>>(query)
161
+ return result[0]?.count ?? 0
162
+ },
163
+
164
+ async deleteBy(where: Where<Document>) {
165
+ const whereQuery = getWhereQuery(where)
166
+ const keyCol = hasUserKey ? 'key' : 'rowid'
167
+
168
+ const results: MutationResult[] = []
169
+ await backend.transaction(async (tx) => {
170
+ const rows = await tx.select<Array<{ k: Key }>>(`SELECT ${keyCol} AS k, rowid FROM ${tableName} ${whereQuery}`)
171
+ if (rows.length === 0) return
172
+
173
+ const allKeys = rows.map((r) => r.k)
174
+
175
+ for (let index = 0; index < allKeys.length; index += DELETE_IN_CHUNK) {
176
+ const chunk = allKeys.slice(index, index + DELETE_IN_CHUNK)
177
+ const placeholders = chunk.map(() => '?').join(',')
178
+ await tx.execute(`DELETE FROM ${tableName} WHERE ${keyCol} IN (${placeholders})`, chunk as unknown as unknown[])
179
+ }
180
+
181
+ for (const k of allKeys) results.push({ key: k, op: 'delete' })
182
+ })
183
+
184
+ return results
185
+ },
186
+
187
+ async batchSet(documents: Document[]) {
188
+ const mutations: MutationResult[] = []
189
+ await backend.transaction(async (tx) => {
190
+ for (const document of documents) {
191
+ const m = await table.set(document, tx)
192
+ mutations.push(m)
193
+ }
194
+ })
195
+ return mutations
196
+ },
197
+ }
198
+
199
+ return table
200
+ }
@@ -0,0 +1,55 @@
1
+ // table.types.ts
2
+ import type { Backend } from './backend'
3
+ import type { Where } from './where'
4
+
5
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
6
+ export type DocType = { [key: string]: any }
7
+ export type KeyTypeAvailable = 'string' | 'number'
8
+
9
+ export interface DbOptions<Document extends DocType> {
10
+ readonly sorBy?: keyof Document
11
+ readonly order?: 'asc' | 'desc'
12
+ readonly tableName: string
13
+ readonly indexes?: Array<keyof Document>
14
+ readonly backend: Backend
15
+ /**
16
+ * Optional key. If omitted, the table uses implicit SQLite ROWID as the key.
17
+ */
18
+ readonly key?: keyof Document
19
+ }
20
+
21
+ interface DbNotGeneric {
22
+ readonly backend: Backend
23
+ }
24
+
25
+ export interface SearchOptions<Document extends DocType, Selected = Document> {
26
+ readonly sorBy?: keyof Document
27
+ readonly order?: 'asc' | 'desc'
28
+ readonly limit?: number
29
+ readonly offset?: number
30
+ readonly where?: Where<Document>
31
+ readonly stepSize?: number
32
+ /**
33
+ * Naive projection. Prefer specialized queries for heavy fan-out graphs.
34
+ */
35
+ readonly select?: (document: Document, meta: { rowId: number }) => Selected
36
+ }
37
+
38
+ export type Key = string | number
39
+
40
+ export type MutationOp = 'insert' | 'update' | 'delete'
41
+ export interface MutationResult {
42
+ key: Key
43
+ op: MutationOp
44
+ }
45
+
46
+ export interface Table<Document extends DocType> extends DbNotGeneric {
47
+ readonly set: (document: Document, backendOverride?: Backend) => Promise<MutationResult>
48
+ readonly batchSet: (documents: Document[]) => Promise<MutationResult[]>
49
+ readonly get: <Selected = Document>(key: Key, selector?: (document: Document) => Selected) => Promise<Selected | undefined>
50
+
51
+ readonly delete: (key: Key) => Promise<MutationResult | undefined>
52
+ readonly search: <Selected = Document>(options?: SearchOptions<Document, Selected>) => AsyncIterableIterator<Selected>
53
+ readonly count: (options?: { where?: Where<Document> }) => Promise<number>
54
+ readonly deleteBy: (where: Where<Document>) => Promise<MutationResult[]>
55
+ }
@@ -0,0 +1,267 @@
1
+ /* eslint-disable sonarjs/no-nested-conditional */
2
+ /* eslint-disable sonarjs/cognitive-complexity */
3
+ // -------------------------------------------------------------
4
+ // Simplified `Where` type: each field may be a `Condition`
5
+ // *or* directly a `Document[K]`/`Document[K][]`, shorthand for "is"/"in".
6
+ // We also allow the special literal "KEY" to filter by the primary‐key column.
7
+ // -------------------------------------------------------------
8
+
9
+ export interface Field {
10
+ readonly table: string
11
+ readonly field: string
12
+ }
13
+
14
+ interface Condition<Document extends Record<string, unknown>, K extends keyof Document = keyof Document> {
15
+ readonly is?: Document[K] | Array<Document[K]>
16
+ readonly isNot?: Document[K] | Array<Document[K]>
17
+ readonly gt?: Document[K] | Array<Document[K]>
18
+ readonly gte?: Document[K] | Array<Document[K]>
19
+ readonly lt?: Document[K] | Array<Document[K]>
20
+ readonly lte?: Document[K] | Array<Document[K]>
21
+ readonly in?: Document[K] | Array<Document[K]>
22
+ readonly notIn?: Document[K] | Array<Document[K]>
23
+ readonly like?: Document[K] | Array<Document[K]>
24
+ }
25
+
26
+ /**
27
+ * We extend `keyof Document` by the special literal "KEY".
28
+ * That means users can write `{ KEY: ... }` in addition to `{ someField: ... }`.
29
+ *
30
+ * - If K extends keyof Document, then primitive values must match Document[K].
31
+ * - If K === "KEY", then primitive values are treated as strings/Array<string>.
32
+ */
33
+ export type Where<Document extends Record<string, unknown>> =
34
+ | {
35
+ [K in keyof Document | 'KEY']?:
36
+ | Condition<Document, K extends keyof Document ? K : keyof Document>
37
+ | (K extends keyof Document ? Document[K] : string)
38
+ | (K extends keyof Document ? Array<Document[K]> : string[])
39
+ }
40
+ | {
41
+ readonly AND?: Array<Where<Document>>
42
+ readonly OR?: Array<Where<Document>>
43
+ readonly NOT?: Where<Document>
44
+ }
45
+
46
+ // -------------------------------------------------------------
47
+ // A tiny helper to escape/inline a single primitive into SQL.
48
+ // -------------------------------------------------------------
49
+ function inlineValue(value: unknown): string {
50
+ if (typeof value === 'string') {
51
+ return `'${(value as string).split("'").join("''")}'`
52
+ }
53
+ if (typeof value === 'number') {
54
+ return (value as number).toString()
55
+ }
56
+ if (typeof value === 'boolean') {
57
+ return (value as boolean) ? '1' : '0'
58
+ }
59
+ return `'${String(value).split("'").join("''")}'`
60
+ }
61
+
62
+ // -------------------------------------------------------------
63
+ // Build the expression for a given field.
64
+ // If field === "KEY", refer directly to the primary‐key column (`key`).
65
+ // Otherwise, extract from JSON `data`.
66
+ // -------------------------------------------------------------
67
+ function getFieldExpr(field: string, value: unknown, tableAlias?: string): string {
68
+ const prefix = tableAlias ? `${tableAlias}.` : ''
69
+ if (field === 'KEY') {
70
+ // Use double‐quotes around key to avoid conflicts with reserved words
71
+ return `"${prefix}key"`
72
+ }
73
+
74
+ // Otherwise, treat as JSON field under "data"
75
+ if (typeof value === 'string') {
76
+ return `CAST(json_extract(${prefix}data, '$.${field}') AS TEXT)`
77
+ }
78
+ if (typeof value === 'boolean') {
79
+ return `CAST(json_extract(${prefix}data, '$.${field}') AS INTEGER)`
80
+ }
81
+ if (typeof value === 'number') {
82
+ return `CAST(json_extract(${prefix}data, '$.${field}') AS NUMERIC)`
83
+ }
84
+ return `json_extract(${prefix}data, '$.${field}')`
85
+ }
86
+
87
+ // -------------------------------------------------------------
88
+ // Valid operators set (for quick membership checks).
89
+ // -------------------------------------------------------------
90
+ const OPS_SET: ReadonlySet<string> = new Set(['is', 'isNot', 'gt', 'gte', 'lt', 'lte', 'in', 'notIn', 'like'])
91
+
92
+ function isUndefined(value: unknown): value is undefined {
93
+ return value === undefined
94
+ }
95
+
96
+ // -------------------------------------------------------------
97
+ // Main recursive parser: turn a `Where<Document>` into a SQL clause
98
+ // (without the leading "WHERE").
99
+ // -------------------------------------------------------------
100
+ export function getWhere<Document extends Record<string, unknown>>(where: Where<Document>, tableAlias?: string): string {
101
+ if (!where || typeof where !== 'object') {
102
+ return ''
103
+ }
104
+
105
+ // ----- Logical branches: AND / OR / NOT -----
106
+ if (!isUndefined(where.AND)) {
107
+ const array = where.AND as Array<Where<Document>>
108
+ if (Array.isArray(array) && array.length > 0) {
109
+ let combined = ''
110
+ let firstAdded = false
111
+ for (const sub of array) {
112
+ const clause = getWhere(sub, tableAlias)
113
+ if (!clause) continue
114
+ if (firstAdded) combined += ' AND '
115
+ combined += clause
116
+ firstAdded = true
117
+ }
118
+ return firstAdded ? `(${combined})` : ''
119
+ }
120
+ return ''
121
+ }
122
+
123
+ if (!isUndefined(where.OR)) {
124
+ const array = where.OR as Array<Where<Document>>
125
+ if (Array.isArray(array) && array.length > 0) {
126
+ let combined = ''
127
+ let firstAdded = false
128
+ for (const sub of array) {
129
+ const clause = getWhere(sub, tableAlias)
130
+ if (!clause) continue
131
+ if (firstAdded) combined += ' OR '
132
+ combined += clause
133
+ firstAdded = true
134
+ }
135
+ return firstAdded ? `(${combined})` : ''
136
+ }
137
+ return ''
138
+ }
139
+
140
+ if (!isUndefined(where.NOT)) {
141
+ const sub = where.NOT as Where<Document>
142
+ if (sub && typeof sub === 'object') {
143
+ const clause = getWhere(sub, tableAlias)
144
+ return clause ? `(NOT ${clause})` : ''
145
+ }
146
+ return ''
147
+ }
148
+
149
+ // ----- Field‐based conditions: default is AND across fields -----
150
+ let fieldClauses = ''
151
+ let anyFieldClause = false
152
+
153
+ for (const key in where as Record<string, unknown>) {
154
+ if (key === 'AND' || key === 'OR' || key === 'NOT') continue
155
+
156
+ const rawValue = (where as Record<string, unknown>)[key]
157
+ if (rawValue == null) continue
158
+
159
+ // If the user provided a primitive or an array, coerce it to a Condition:
160
+ // - single primitive → { is: rawVal }
161
+ // - array → { in: rawVal }
162
+ let cond: Condition<Document, typeof key>
163
+ if (typeof rawValue !== 'object' || Array.isArray(rawValue)) {
164
+ cond = Array.isArray(rawValue) ? { in: rawValue } : ({ is: rawValue } as Condition<Document, typeof key>)
165
+ } else {
166
+ cond = rawValue as Condition<Document, typeof key>
167
+ }
168
+
169
+ // Iterate only over real operator keys that exist on this `cond`
170
+ for (const opKey of Object.keys(cond) as Array<keyof typeof cond>) {
171
+ if (!OPS_SET.has(opKey as string)) continue
172
+ const rawOpValue = cond[opKey]
173
+ if (rawOpValue == null) continue
174
+
175
+ // Always treat it as an array for uniformity:
176
+ const array = Array.isArray(rawOpValue) ? (rawOpValue as unknown[]) : [rawOpValue]
177
+ if (array.length === 0) continue
178
+
179
+ // Handle `is` / `isNot` / `in` / `notIn`
180
+ if (opKey === 'is' || opKey === 'isNot' || opKey === 'in' || opKey === 'notIn') {
181
+ const [firstValue] = array
182
+ const fieldExpr = getFieldExpr(key, firstValue, tableAlias)
183
+
184
+ // Build comma‐separated list without using `.map()`
185
+ let inList = ''
186
+ if (array.length > 1) {
187
+ for (const [index, elt] of array.entries()) {
188
+ if (index > 0) inList += ','
189
+ inList += inlineValue(elt)
190
+ }
191
+ }
192
+
193
+ switch (opKey) {
194
+ case 'is': {
195
+ fieldClauses +=
196
+ array.length > 1
197
+ ? (anyFieldClause ? ' AND ' : '') + `${fieldExpr} IN (${inList})`
198
+ : (anyFieldClause ? ' AND ' : '') + `${fieldExpr} = ${inlineValue(array[0])}`
199
+ break
200
+ }
201
+ case 'isNot': {
202
+ fieldClauses +=
203
+ array.length > 1
204
+ ? (anyFieldClause ? ' AND ' : '') + `${fieldExpr} NOT IN (${inList})`
205
+ : (anyFieldClause ? ' AND ' : '') + `${fieldExpr} <> ${inlineValue(array[0])}`
206
+ break
207
+ }
208
+ case 'in': {
209
+ fieldClauses +=
210
+ array.length > 1
211
+ ? (anyFieldClause ? ' AND ' : '') + `${fieldExpr} IN (${inList})`
212
+ : (anyFieldClause ? ' AND ' : '') + `${fieldExpr} IN (${inlineValue(array[0])})`
213
+ break
214
+ }
215
+ case 'notIn': {
216
+ fieldClauses +=
217
+ array.length > 1
218
+ ? (anyFieldClause ? ' AND ' : '') + `${fieldExpr} NOT IN (${inList})`
219
+ : (anyFieldClause ? ' AND ' : '') + `${fieldExpr} NOT IN (${inlineValue(array[0])})`
220
+ break
221
+ }
222
+ }
223
+
224
+ anyFieldClause = true
225
+ continue
226
+ }
227
+
228
+ // Handle comparisons: gt, gte, lt, lte, like
229
+ for (const v of array) {
230
+ const fieldExpr = getFieldExpr(key, v, tableAlias)
231
+ switch (opKey) {
232
+ case 'gt': {
233
+ fieldClauses += (anyFieldClause ? ' AND ' : '') + `${fieldExpr} > ${inlineValue(v)}`
234
+ break
235
+ }
236
+ case 'gte': {
237
+ fieldClauses += (anyFieldClause ? ' AND ' : '') + `${fieldExpr} >= ${inlineValue(v)}`
238
+ break
239
+ }
240
+ case 'lt': {
241
+ fieldClauses += (anyFieldClause ? ' AND ' : '') + `${fieldExpr} < ${inlineValue(v)}`
242
+ break
243
+ }
244
+ case 'lte': {
245
+ fieldClauses += (anyFieldClause ? ' AND ' : '') + `${fieldExpr} <= ${inlineValue(v)}`
246
+ break
247
+ }
248
+ case 'like': {
249
+ fieldClauses += (anyFieldClause ? ' AND ' : '') + `${fieldExpr} LIKE ${inlineValue(v)}`
250
+ break
251
+ }
252
+ }
253
+ anyFieldClause = true
254
+ }
255
+ }
256
+ }
257
+
258
+ return anyFieldClause ? `(${fieldClauses})` : ''
259
+ }
260
+
261
+ // -------------------------------------------------------------
262
+ // Wrap `parse(...)` in "WHERE (…)". If empty, return "".
263
+ // -------------------------------------------------------------
264
+ export function getWhereQuery<Document extends Record<string, unknown>>(where: Where<Document>): string {
265
+ const clause = getWhere(where)
266
+ return clause ? `WHERE ${clause}` : ''
267
+ }
@@ -0,0 +1,76 @@
1
+ import { useCallback, useDebugValue, useId, useLayoutEffect, useMemo, type DependencyList } from 'react'
2
+ import type { SyncTable } from './create-sqlite-state'
3
+ import type { DocType } from './table/table.types'
4
+ import { isError, isPromise } from '../utils/is'
5
+ import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/shim/with-selector'
6
+ import type { Where } from './table/where'
7
+
8
+ export interface SqLiteActions {
9
+ readonly next: () => Promise<boolean>
10
+ readonly reset: () => Promise<void>
11
+ }
12
+
13
+ export interface UseSearchOptions<Document extends DocType, Selected = Document> {
14
+ readonly sorBy?: keyof Document
15
+ readonly order?: 'asc' | 'desc'
16
+ readonly limit?: number
17
+ readonly offset?: number
18
+ readonly where?: Where<Document>
19
+ readonly stepSize?: number
20
+ /**
21
+ * Naive projection. Prefer specialized queries for heavy fan-out graphs.
22
+ */
23
+ readonly select?: (document: Document) => Selected
24
+ }
25
+
26
+ export function useSqliteValue<Document extends DocType, Selected = Document>(
27
+ state: SyncTable<Document>,
28
+ options: UseSearchOptions<Document, Selected> = {},
29
+ deps: DependencyList = [],
30
+ ): [undefined extends Selected ? Document[] : Selected[], SqLiteActions] {
31
+ const { select } = options
32
+
33
+ const id = useId()
34
+
35
+ useLayoutEffect(() => {
36
+ state.updateSearchOptions(id, { ...options, select: undefined })
37
+ // eslint-disable-next-line react-hooks/exhaustive-deps
38
+ }, deps)
39
+
40
+ const selector = useCallback(
41
+ (documents: Document[]) => {
42
+ // eslint-disable-next-line unicorn/no-array-callback-reference
43
+ return select ? documents.map(select) : (documents as unknown as Selected[])
44
+ },
45
+ [select],
46
+ )
47
+
48
+ const subscribe = useCallback(
49
+ (onStorageChange: () => void) => {
50
+ return state.subscribe(id, onStorageChange)
51
+ },
52
+ [state, id],
53
+ )
54
+
55
+ const getSnapshot = useCallback(() => {
56
+ return state.getSnapshot(id)
57
+ }, [state, id])
58
+
59
+ const value = useSyncExternalStoreWithSelector<Document[], Selected[]>(subscribe, getSnapshot, getSnapshot, selector)
60
+
61
+ useDebugValue(value)
62
+ if (isPromise(value)) {
63
+ throw value
64
+ }
65
+ if (isError(value)) {
66
+ throw value
67
+ }
68
+
69
+ const actions = useMemo((): SqLiteActions => {
70
+ return {
71
+ next: () => state.next(id),
72
+ reset: () => state.refresh(id),
73
+ }
74
+ }, [id, state])
75
+ return [value as undefined extends Selected ? Document[] : Selected[], actions]
76
+ }
package/src/types.ts CHANGED
@@ -63,4 +63,5 @@ export interface State<T> extends GetState<T> {
63
63
  */
64
64
  withName: (name: string) => State<T>
65
65
  isSet: true
66
+ cache: Cache<T>
66
67
  }
@@ -1,4 +1,4 @@
1
- import type { Cache, IsEqual } from '../types'
1
+ import type { Cache, IsEqual, State } from '../types'
2
2
  import { isAbortError, isEqualBase, isPromise, isUndefined } from './is'
3
3
 
4
4
  export interface CancelablePromise<T> {
@@ -46,7 +46,11 @@ export function canUpdate<T>(cache: Cache<T>, isEqual: IsEqual<T> = isEqualBase)
46
46
  /**
47
47
  * Handle async updates for `create` and `select`
48
48
  */
49
- export function handleAsyncUpdate<T>(cache: Cache<T>, emit: () => void, value: T): T {
49
+ export function handleAsyncUpdate<T>(state: State<T>, value: T): T {
50
+ const {
51
+ cache,
52
+ emitter: { emit },
53
+ } = state
50
54
  if (!isPromise(value)) {
51
55
  return value
52
56
  }
package/types/create.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { DefaultValue, IsEqual, State } from './types';
2
- export declare const stateScheduler: {
3
- add<T>(id: number, option: import("./scheduler").SchedulerOptions<T>): () => void;
4
- schedule<T>(id: number, value: T): void;
2
+ export declare const STATE_SCHEDULER: {
3
+ add<T>(id: string | number | symbol, option: import("./scheduler").SchedulerOptions<T>): () => void;
4
+ schedule<T>(id: string | number | symbol, value: T): void;
5
5
  };
6
6
  /**
7
7
  * Create state from a default value.
@@ -1,11 +1,20 @@
1
1
  export declare const THRESHOLD = 0.2;
2
2
  export declare const THRESHOLD_ITEMS = 10;
3
3
  export declare const RESCHEDULE_COUNT = 0;
4
+ type ScheduleId = string | number | symbol;
4
5
  export interface SchedulerOptions<T> {
5
6
  readonly onResolveItem?: (item: T) => void;
6
- readonly onFinish: () => void;
7
+ readonly onScheduleDone: () => void | Promise<void>;
7
8
  }
9
+ /**
10
+ * A simple scheduler to batch updates and avoid blocking the main thread
11
+ * It uses a combination of time-based and count-based strategies to determine when to flush the queue.
12
+ * - Time-based: If the time taken to process the current batch is less than a threshold (THRESHOLD), it continues processing.
13
+ * - Count-based: If the ScheduleId of items in the batch exceeds a certain limit (THRESHOLD_ITEMS), it defers processing to the next microtask.
14
+ * @returns An object with methods to add listeners and schedule tasks.
15
+ */
8
16
  export declare function createScheduler(): {
9
- add<T>(id: number, option: SchedulerOptions<T>): () => void;
10
- schedule<T>(id: number, value: T): void;
17
+ add<T>(id: ScheduleId, option: SchedulerOptions<T>): () => void;
18
+ schedule<T>(id: ScheduleId, value: T): void;
11
19
  };
20
+ export {};
@@ -0,0 +1,22 @@
1
+ import type { DbOptions, DocType, Key, MutationResult, SearchOptions } from './table/table.types';
2
+ import type { Where } from './table/where';
3
+ type SearchId = string;
4
+ export interface SyncTable<Document extends DocType> {
5
+ readonly updateSearchOptions: <Selected = Document>(searchId: SearchId, options: SearchOptions<Document, Selected>) => void;
6
+ readonly subscribe: (searchId: SearchId, listener: () => void) => () => void;
7
+ readonly getSnapshot: (searchId: SearchId) => Document[];
8
+ readonly refresh: (searchId: SearchId) => Promise<void>;
9
+ readonly set: (document: Document) => Promise<MutationResult>;
10
+ readonly batchSet: (documents: Document[]) => Promise<MutationResult[]>;
11
+ readonly get: <Selected = Document>(key: Key, selector?: (document: Document) => Selected) => Promise<Selected | undefined>;
12
+ readonly delete: (key: Key) => Promise<MutationResult | undefined>;
13
+ readonly search: <Selected = Document>(options?: SearchOptions<Document, Selected>) => AsyncIterableIterator<Selected>;
14
+ readonly count: (options?: {
15
+ where?: Where<Document>;
16
+ }) => Promise<number>;
17
+ readonly deleteBy: (where: Where<Document>) => Promise<MutationResult[]>;
18
+ readonly destroy: () => void;
19
+ readonly next: (searchId: SearchId) => Promise<boolean>;
20
+ }
21
+ export declare function createSqliteState<Document extends DocType>(options: DbOptions<Document>): Promise<SyncTable<Document>>;
22
+ export {};