muya 2.5.1 → 2.5.3

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.
@@ -2,7 +2,18 @@ import { STATE_SCHEDULER } from '../create'
2
2
  import { getId } from '../utils/id'
3
3
  import type { Backend } from './table'
4
4
  import { createTable } from './table/table'
5
- import type { DbOptions, DocType, Key, MutationResult, SearchOptions, Table } from './table/table.types'
5
+ import type {
6
+ DbOptions,
7
+ DocType,
8
+ DotPath,
9
+ GetFieldType,
10
+ GroupByOptions,
11
+ GroupByResult,
12
+ Key,
13
+ MutationResult,
14
+ SearchOptions,
15
+ Table,
16
+ } from './table/table.types'
6
17
  import type { Where } from './table/where'
7
18
 
8
19
  export interface CreateSqliteOptions<Document extends DocType> extends Omit<DbOptions<Document>, 'backend'> {
@@ -26,6 +37,10 @@ export interface SyncTable<Document extends DocType> {
26
37
  readonly count: (options?: { where?: Where<Document> }) => Promise<number>
27
38
  readonly deleteBy: (where: Where<Document>) => Promise<MutationResult<Document>[]>
28
39
  readonly clear: () => Promise<void>
40
+ readonly groupBy: <Field extends DotPath<Document>>(
41
+ field: Field,
42
+ options?: GroupByOptions<Document>,
43
+ ) => Promise<Array<GroupByResult<GetFieldType<Document, Field>>>>
29
44
  }
30
45
 
31
46
  /**
@@ -139,6 +154,10 @@ export function createSqliteState<Document extends DocType>(options: CreateSqlit
139
154
  const table = await getTable()
140
155
  return await table.count(countOptions)
141
156
  },
157
+ async groupBy(field, groupByOptions) {
158
+ const table = await getTable()
159
+ return await table.groupBy(field, groupByOptions)
160
+ },
142
161
  }
143
162
 
144
163
  return state
@@ -4,7 +4,18 @@
4
4
  /* eslint-disable @typescript-eslint/no-shadow */
5
5
  /* eslint-disable no-shadow */
6
6
  import type { Backend } from './backend'
7
- import type { Table, DbOptions, DocType, Key, SearchOptions, MutationResult } from './table.types'
7
+ import type {
8
+ Table,
9
+ DbOptions,
10
+ DocType,
11
+ Key,
12
+ SearchOptions,
13
+ MutationResult,
14
+ GroupByResult,
15
+ GroupByOptions,
16
+ DotPath,
17
+ GetFieldType,
18
+ } from './table.types'
8
19
  import { unicodeTokenizer, type FtsTokenizerOptions } from './tokenizer'
9
20
  import type { Where } from './where'
10
21
  import { getWhereQuery } from './where'
@@ -299,6 +310,23 @@ export async function createTable<Document extends DocType>(options: DbOptions<D
299
310
  await backend.execute(`DELETE FROM ${tableName}`)
300
311
  },
301
312
 
313
+ async groupBy<Field extends DotPath<Document>>(
314
+ field: Field,
315
+ options: GroupByOptions<Document> = {},
316
+ ): Promise<Array<GroupByResult<GetFieldType<Document, Field>>>> {
317
+ const whereSql = getWhereQuery<Document>(options.where, tableName)
318
+ const jsonPath = toJsonPath(String(field))
319
+ const query = `
320
+ SELECT json_extract(data, '${jsonPath}') AS groupKey, COUNT(*) AS count
321
+ FROM ${tableName}
322
+ ${whereSql}
323
+ GROUP BY groupKey
324
+ `
325
+ type FieldType = GetFieldType<Document, Field>
326
+ const results = await backend.select<Array<{ groupKey: FieldType; count: number }>>(query)
327
+ return results.map((row) => ({ key: row.groupKey, count: row.count }))
328
+ },
329
+
302
330
  async batchSet(documents: Document[]) {
303
331
  const mutations: MutationResult<Document>[] = []
304
332
  await backend.transaction(async (tx) => {
@@ -30,6 +30,18 @@ type DotPathRaw<T, D extends number = 5> = [D] extends [never]
30
30
 
31
31
  export type DotPath<T> = DotPathRaw<MakeAllFieldAsRequired<T>>
32
32
 
33
+ /**
34
+ * Extract the value type at a given dot path
35
+ * e.g., GetFieldType<{ user: { name: string } }, 'user.name'> = string
36
+ */
37
+ export type GetFieldType<T, Path extends string> = Path extends `${infer First}.${infer Rest}`
38
+ ? First extends keyof T
39
+ ? GetFieldType<T[First], Rest>
40
+ : never
41
+ : Path extends keyof T
42
+ ? T[Path]
43
+ : never
44
+
33
45
  // Built-in FTS5 tokenizers
34
46
  export type FtsTokenizer =
35
47
  | 'porter' // English stemming
@@ -86,6 +98,15 @@ interface MutationResultUpdateInsert<T> extends MutationResultBase<T> {
86
98
 
87
99
  export type MutationResult<T> = MutationResultDelete<T> | MutationResultUpdateInsert<T>
88
100
 
101
+ export interface GroupByResult<K> {
102
+ readonly key: K
103
+ readonly count: number
104
+ }
105
+
106
+ export interface GroupByOptions<Document extends DocType> {
107
+ readonly where?: Where<Document>
108
+ }
109
+
89
110
  export interface Table<Document extends DocType> extends DbNotGeneric {
90
111
  readonly set: (document: Document, backendOverride?: Backend) => Promise<MutationResult<Document>>
91
112
  readonly batchSet: (documents: Document[]) => Promise<MutationResult<Document>[]>
@@ -97,6 +118,10 @@ export interface Table<Document extends DocType> extends DbNotGeneric {
97
118
  readonly count: (options?: { where?: Where<Document> }) => Promise<number>
98
119
  readonly deleteBy: (where: Where<Document>) => Promise<MutationResult<Document>[]>
99
120
  readonly clear: () => Promise<void>
121
+ readonly groupBy: <Field extends DotPath<Document>>(
122
+ field: Field,
123
+ options?: GroupByOptions<Document>,
124
+ ) => Promise<Array<GroupByResult<GetFieldType<Document, Field>>>>
100
125
  }
101
126
 
102
127
  export type MakeAllFieldAsRequired<T> = {
@@ -1,11 +1,25 @@
1
1
  /* eslint-disable sonarjs/cognitive-complexity */
2
- import { useCallback, useLayoutEffect, useReducer, useRef, type DependencyList } from 'react'
2
+ import { useCallback, useLayoutEffect, useReducer, useRef, useState, type DependencyList } from 'react'
3
3
  import type { SyncTable } from './create-sqlite'
4
4
  import type { DocType, Key, SqlSeachOptions } from './table/table.types'
5
5
  import { DEFAULT_PAGE_SIZE } from './table'
6
6
  import { shallow } from '../utils/shallow'
7
7
  const MAX_ITERATIONS = 10_000
8
8
 
9
+ /**
10
+ * Shallow compare two dependency arrays
11
+ * @param previousDeps Previous deps array
12
+ * @param nextDeps Next deps array
13
+ * @returns True if arrays have same length and all items are strictly equal
14
+ */
15
+ function shallowEqualDeps(previousDeps: DependencyList, nextDeps: DependencyList): boolean {
16
+ if (previousDeps.length !== nextDeps.length) return false
17
+ for (const [index, previousDep] of previousDeps.entries()) {
18
+ if (!Object.is(previousDep, nextDeps[index])) return false
19
+ }
20
+ return true
21
+ }
22
+
9
23
  export interface SqLiteActions {
10
24
  /**
11
25
  * Load the next page of results and return if isDone to show more results.
@@ -17,8 +31,15 @@ export interface SqLiteActions {
17
31
  * @returns void
18
32
  */
19
33
  readonly reset: () => Promise<void>
34
+ /**
35
+ * Map of document keys to their index in the results array.
36
+ */
20
37
  readonly keysIndex: Map<Key, number>
21
- readonly isResetting?: boolean
38
+ /**
39
+ * True when deps changed but fresh data hasn't loaded yet.
40
+ * Use this to show stale/dimmed UI while new results are loading.
41
+ */
42
+ readonly isStale: boolean
22
43
  }
23
44
 
24
45
  export interface UseSearchOptions<Document extends DocType, Selected = Document> extends SqlSeachOptions<Document> {
@@ -48,6 +69,11 @@ export function useSqliteValue<Document extends DocType, Selected = Document>(
48
69
  const keysIndex = useRef(new Map<Key, number>())
49
70
  const iteratorRef = useRef<AsyncIterableIterator<{ doc: Document; meta: { key: Key } }> | null>(null)
50
71
 
72
+ // Track "settled" deps - the deps value when data last finished loading
73
+ // isStale is derived: true when current deps differ from settled deps
74
+ const [settledDeps, setSettledDeps] = useState<DependencyList | null>(null)
75
+ const isStale = settledDeps === null || !shallowEqualDeps(settledDeps, deps)
76
+
51
77
  const updateIterator = useCallback(() => {
52
78
  // eslint-disable-next-line sonarjs/no-unused-vars
53
79
  const { select: _ignore, ...resetOptions } = options
@@ -198,17 +224,26 @@ export function useSqliteValue<Document extends DocType, Selected = Document>(
198
224
  }, [state])
199
225
 
200
226
  useLayoutEffect(() => {
227
+ // Capture current deps for this effect invocation
228
+ const currentDeps = deps
201
229
  resetDataAndUpdateIterator()
202
- nextPage()
230
+ nextPage().then(() => {
231
+ // Mark these deps as settled when data finishes loading
232
+ setSettledDeps(currentDeps)
233
+ })
203
234
  // eslint-disable-next-line react-hooks/exhaustive-deps
204
235
  }, deps)
205
236
 
206
237
  const resetCb = useCallback(async () => {
238
+ // Set settledDeps to null to make isStale=true during reset
239
+ setSettledDeps(null)
207
240
  resetDataAndUpdateIterator()
208
241
  await nextPage()
209
- }, [nextPage, resetDataAndUpdateIterator])
242
+ // After data loads, mark current deps as settled
243
+ setSettledDeps(deps)
244
+ }, [nextPage, resetDataAndUpdateIterator, deps])
210
245
 
211
- return [itemsRef.current, { nextPage, reset: resetCb, keysIndex: keysIndex.current }] as [
246
+ return [itemsRef.current, { nextPage, reset: resetCb, keysIndex: keysIndex.current, isStale }] as [
212
247
  (undefined extends Selected ? Document[] : Selected[]) | null,
213
248
  SqLiteActions,
214
249
  ]
@@ -1,5 +1,5 @@
1
1
  import type { Backend } from './table';
2
- import type { DbOptions, DocType, Key, MutationResult, SearchOptions } from './table/table.types';
2
+ import type { DbOptions, DocType, DotPath, GetFieldType, GroupByOptions, GroupByResult, Key, MutationResult, SearchOptions } from './table/table.types';
3
3
  import type { Where } from './table/where';
4
4
  export interface CreateSqliteOptions<Document extends DocType> extends Omit<DbOptions<Document>, 'backend'> {
5
5
  readonly backend: Backend | Promise<Backend>;
@@ -21,6 +21,7 @@ export interface SyncTable<Document extends DocType> {
21
21
  }) => Promise<number>;
22
22
  readonly deleteBy: (where: Where<Document>) => Promise<MutationResult<Document>[]>;
23
23
  readonly clear: () => Promise<void>;
24
+ readonly groupBy: <Field extends DotPath<Document>>(field: Field, options?: GroupByOptions<Document>) => Promise<Array<GroupByResult<GetFieldType<Document, Field>>>>;
24
25
  }
25
26
  /**
26
27
  * Create a SyncTable that wraps a Table and provides reactive capabilities
@@ -19,6 +19,11 @@ type DotPathRaw<T, D extends number = 5> = [D] extends [never] ? never : T exten
19
19
  [K in Extract<keyof T, string>]: T[K] extends object ? K | `${K}.${DotPathRaw<T[K], Previous[D]>}` : K;
20
20
  }[Extract<keyof T, string>] : never;
21
21
  export type DotPath<T> = DotPathRaw<MakeAllFieldAsRequired<T>>;
22
+ /**
23
+ * Extract the value type at a given dot path
24
+ * e.g., GetFieldType<{ user: { name: string } }, 'user.name'> = string
25
+ */
26
+ export type GetFieldType<T, Path extends string> = Path extends `${infer First}.${infer Rest}` ? First extends keyof T ? GetFieldType<T[First], Rest> : never : Path extends keyof T ? T[Path] : never;
22
27
  export type FtsTokenizer = 'porter' | 'simple' | 'icu' | 'unicode61' | FtsTokenizerOptions;
23
28
  export interface FtsType<Document extends DocType> {
24
29
  readonly type: 'fts';
@@ -59,6 +64,13 @@ interface MutationResultUpdateInsert<T> extends MutationResultBase<T> {
59
64
  document: T;
60
65
  }
61
66
  export type MutationResult<T> = MutationResultDelete<T> | MutationResultUpdateInsert<T>;
67
+ export interface GroupByResult<K> {
68
+ readonly key: K;
69
+ readonly count: number;
70
+ }
71
+ export interface GroupByOptions<Document extends DocType> {
72
+ readonly where?: Where<Document>;
73
+ }
62
74
  export interface Table<Document extends DocType> extends DbNotGeneric {
63
75
  readonly set: (document: Document, backendOverride?: Backend) => Promise<MutationResult<Document>>;
64
76
  readonly batchSet: (documents: Document[]) => Promise<MutationResult<Document>[]>;
@@ -71,6 +83,7 @@ export interface Table<Document extends DocType> extends DbNotGeneric {
71
83
  }) => Promise<number>;
72
84
  readonly deleteBy: (where: Where<Document>) => Promise<MutationResult<Document>[]>;
73
85
  readonly clear: () => Promise<void>;
86
+ readonly groupBy: <Field extends DotPath<Document>>(field: Field, options?: GroupByOptions<Document>) => Promise<Array<GroupByResult<GetFieldType<Document, Field>>>>;
74
87
  }
75
88
  export type MakeAllFieldAsRequired<T> = {
76
89
  [K in keyof T]-?: T[K] extends object ? MakeAllFieldAsRequired<T[K]> : T[K];
@@ -12,8 +12,15 @@ export interface SqLiteActions {
12
12
  * @returns void
13
13
  */
14
14
  readonly reset: () => Promise<void>;
15
+ /**
16
+ * Map of document keys to their index in the results array.
17
+ */
15
18
  readonly keysIndex: Map<Key, number>;
16
- readonly isResetting?: boolean;
19
+ /**
20
+ * True when deps changed but fresh data hasn't loaded yet.
21
+ * Use this to show stale/dimmed UI while new results are loading.
22
+ */
23
+ readonly isStale: boolean;
17
24
  }
18
25
  export interface UseSearchOptions<Document extends DocType, Selected = Document> extends SqlSeachOptions<Document> {
19
26
  /**