muya 2.4.92 → 2.4.94

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.
@@ -0,0 +1 @@
1
+ import{createTable as n}from"../table";import{bunMemoryBackend as c}from"../table/bun-backend";describe("where clauses",()=>{const a=c();it("should handle where where array of conditions",async()=>{const e=await n({backend:a,tableName:"TestTableNestedOptional",key:"id",indexes:["content"]});await e.set({id:"1",content:"The quick brown fox"}),await e.set({id:"2",content:"The jumps over the lazy dog"}),await e.set({id:"3"});const t=[];for await(const i of e.search({where:{content:{like:["The%"]}}}))t.push(i);expect(t.length).toBe(2),expect(t[0].id).toBe("1")}),it("should create nested index for optional nested fields",async()=>{const e=await n({backend:a,tableName:"TestTableNestedOptional",key:"id",indexes:["fts:info.content"]});await e.set({id:"1",info:{content:"The quick brown fox"}}),await e.set({id:"2",info:{content:"jumps over the lazy dog"}}),await e.set({id:"3"});const t=[];for await(const s of e.search({where:{info:{content:{like:["The%"]}}}}))t.push(s);expect(t.length).toBe(1),expect(t[0].id).toBe("1");const i=[];for await(const s of e.search({where:{OR:[{info:{content:{like:["The%"]}}},{info:{content:{like:["jumps%"]}}}]}}))i.push(s);expect(i.length).toBe(2);const o=[];for await(const s of e.search({where:{info:{content:"nonexistent"}}}))o.push(s);expect(o.length).toBe(0)}),it("should handle FTS queries",async()=>{const e=await n({backend:a,tableName:"TestTableFts",key:"id",indexes:["fts:content"]});await e.set({id:"1",content:"The quick brown fox"}),await e.set({id:"2",content:"Jumps over the lazy dog"}),await e.set({id:"3",content:"Another document"});const t=[];for await(const i of e.search({where:{content:{fts:"quick"}}}))t.push(i);expect(t.length).toBe(1),expect(t[0].id).toBe("1")}),it("should handle nested where conditions",async()=>{const e=await n({backend:a,tableName:"TestTableNested",key:"id",indexes:["info.content"]});await e.set({id:"1",info:{content:"Nested quick brown fox"}}),await e.set({id:"2",info:{content:"Nested jumps over the lazy dog"}});const t=[];for await(const i of e.search({where:{info:{content:{like:"Nested%"}}}}))t.push(i);expect(t.length).toBe(2)}),it("should handle complex operators",async()=>{const e=await n({backend:a,tableName:"TestTableComplex",key:"id",indexes:["value"]});await e.set({id:"1",value:10}),await e.set({id:"2",value:20}),await e.set({id:"3",value:30});const t=[];for await(const i of e.search({where:{value:{gt:15,lt:25}},sortBy:"value"}))t.push(i);expect(t.length).toBe(1),expect(t[0].id).toBe("2")}),it("should handle NOT conditions",async()=>{const e=await n({backend:a,tableName:"TestTableNot",key:"id",indexes:["value"]});await e.set({id:"1",value:"apple"}),await e.set({id:"2",value:"banana"}),await e.set({id:"3",value:"cherry"});const t=[];for await(const i of e.search({where:{NOT:{value:"banana"}}}))t.push(i);expect(t.length).toBe(2),expect(t.map(i=>i.value)).toEqual(["apple","cherry"])}),it("should handle AND conditions",async()=>{const e=await n({backend:a,tableName:"TestTableAnd",key:"id",indexes:["category","price"]});await e.set({id:"1",category:"fruit",price:10}),await e.set({id:"2",category:"fruit",price:20}),await e.set({id:"3",category:"vegetable",price:15});const t=[];for await(const i of e.search({where:{AND:[{category:"fruit"},{price:{lt:15}}]}}))t.push(i);expect(t.length).toBe(1),expect(t[0].id).toBe("1")}),it("should handle OR conditions",async()=>{const e=await n({backend:a,tableName:"TestTableOr",key:"id",indexes:["type"]});await e.set({id:"1",type:"A"}),await e.set({id:"2",type:"B"}),await e.set({id:"3",type:"C"});const t=[];for await(const i of e.search({where:{OR:[{type:"A"},{type:"C"}]}}))t.push(i);expect(t.length).toBe(2),expect(t.map(i=>i.type)).toEqual(["A","C"])}),it("should handle nested AND/OR/NOT conditions",async()=>{const e=await n({backend:a,tableName:"TestTableNestedLogic",key:"id",indexes:["category","price"]});await e.set({id:"1",category:"fruit",price:10}),await e.set({id:"2",category:"fruit",price:20}),await e.set({id:"3",category:"fruit",price:15});const t={AND:[{category:{is:"fruit"}},{OR:[{price:{lt:15}},{price:{is:15}}]}]},i=[];for await(const s of e.search({}))i.push(s);expect(i.length).toBe(3);const o=[];for await(const s of e.search({where:t}))o.push(s);expect(o.length).toBe(2)})});
@@ -1 +1 @@
1
- function a(n){return typeof n=="string"?`'${n.replaceAll("'","''")}'`:typeof n=="number"?n.toString():typeof n=="boolean"?n?"1":"0":`'${String(n).replaceAll("'","''")}'`}function A(n,r,t){const e=t?`${t}.`:"";return n==="KEY"?`"${e}key"`:typeof r=="string"?`CAST(json_extract(${e}data, '$.${n}') AS TEXT)`:typeof r=="number"?`CAST(json_extract(${e}data, '$.${n}') AS NUMERIC)`:typeof r=="boolean"?`CAST(json_extract(${e}data, '$.${n}') AS INTEGER)`:`json_extract(${e}data, '$.${n}')`}const N=new Set(["is","isNot","gt","gte","lt","lte","in","notIn","like","fts"]);function p(n,r=""){const t={};for(const[e,c]of Object.entries(n)){if(e==="AND"||e==="OR"||e==="NOT"){t[e]=c;continue}const $=r?`${r}.${e}`:e;c&&typeof c=="object"&&!Array.isArray(c)&&!Object.keys(c).some(i=>N.has(i))?Object.assign(t,p(c,$)):t[$]=c}return t}function l(n,r,t){if(!n||typeof n!="object")return"";if(n.AND){const i=Array.isArray(n.AND)?n.AND.map(u=>l(u,r,t)).filter(Boolean):[];return i.length>0?`(${i.join(" AND ")})`:""}if(n.OR){const i=Array.isArray(n.OR)?n.OR.map(u=>l(u,r,t)).filter(Boolean):[];return i.length>0?`(${i.join(" OR ")})`:""}if(n.NOT){const i=l(n.NOT,r,t);return i?`(NOT ${i})`:""}const e=p(n);let c="",$=!1;for(const[i,u]of Object.entries(e)){if(u==null)continue;let T;typeof u!="object"||Array.isArray(u)?T=Array.isArray(u)?{in:u}:{is:u}:T=u;for(const s of Object.keys(T)){const d=T[s];if(d==null)continue;const y=Array.isArray(d)?d:[d];if(y.length!==0){if(s==="fts"){if(!t)throw new Error("FTS requires tableName for JOIN reference");const o=y.map(f=>`EXISTS (SELECT 1 FROM ${t}_fts f WHERE f.rowid = ${r??t}.rowid AND ${t}_fts MATCH ${a(f)})`).join(" AND ");c+=($?" AND ":"")+o,$=!0;continue}if(s==="is"||s==="isNot"||s==="in"||s==="notIn"){const o=A(i,y[0],r),f=y.map(a).join(","),g=s==="is"?y.length>1?`${o} IN (${f})`:`${o} = ${a(y[0])}`:s==="isNot"?y.length>1?`${o} NOT IN (${f})`:`${o} <> ${a(y[0])}`:s==="in"?`${o} IN (${f})`:`${o} NOT IN (${f})`;c+=($?" AND ":"")+g,$=!0;continue}for(const o of y){const f=A(i,o,r),g=s==="gt"?`${f} > ${a(o)}`:s==="gte"?`${f} >= ${a(o)}`:s==="lt"?`${f} < ${a(o)}`:s==="lte"?`${f} <= ${a(o)}`:`${f} LIKE ${a(o)}`;c+=($?" AND ":"")+g,$=!0}}}}return $?`(${c})`:""}function k(n,r){if(!n)return"";const t=l(n,void 0,r);return t?`WHERE ${t}`:""}export{l as getWhere,k as getWhereQuery};
1
+ function $(n){return typeof n=="string"?`'${n.replaceAll("'","''")}'`:typeof n=="number"?n.toString():typeof n=="boolean"?n?"1":"0":`'${String(n).replaceAll("'","''")}'`}function A(n,r,t){const o=t?`${t}.`:"";return n==="KEY"?`"${o}key"`:typeof r=="string"?`CAST(json_extract(${o}data, '$.${n}') AS TEXT)`:typeof r=="number"?`CAST(json_extract(${o}data, '$.${n}') AS NUMERIC)`:typeof r=="boolean"?`CAST(json_extract(${o}data, '$.${n}') AS INTEGER)`:`json_extract(${o}data, '$.${n}')`}const p=new Set(["is","isNot","gt","gte","lt","lte","in","notIn","like","fts"]);function R(n,r=""){const t={};for(const[o,c]of Object.entries(n)){if(o==="AND"||o==="OR"||o==="NOT"){t[o]=c;continue}const a=r?`${r}.${o}`:o;c&&typeof c=="object"&&!Array.isArray(c)&&!Object.keys(c).some(i=>p.has(i))?Object.assign(t,R(c,a)):t[a]=c}return t}function T(n,r,t){if(!n||typeof n!="object")return"";if(n.AND){const i=Array.isArray(n.AND)?n.AND.map(u=>T(u,r,t)).filter(Boolean):[];return i.length>0?`(${i.join(" AND ")})`:""}if(n.OR){const i=Array.isArray(n.OR)?n.OR.map(u=>T(u,r,t)).filter(Boolean):[];return i.length>0?`(${i.join(" OR ")})`:""}if(n.NOT){const i=T(n.NOT,r,t);return i?`(NOT ${i})`:""}const o=R(n);let c="",a=!1;for(const[i,u]of Object.entries(o)){if(u==null)continue;let d;typeof u!="object"||Array.isArray(u)?d=Array.isArray(u)?{in:u}:{is:u}:d=u;for(const s of Object.keys(d)){const l=d[s];if(l==null)continue;const y=Array.isArray(l)?l:[l];if(y.length!==0){if(s==="fts"){if(!t)throw new Error("FTS requires tableName for JOIN reference");const e=y.map(f=>`EXISTS (SELECT 1 FROM ${t}_fts f WHERE f.rowid = ${r??t}.rowid AND ${t}_fts MATCH ${$(f)})`).join(" AND ");c+=(a?" AND ":"")+e,a=!0;continue}if(s==="is"||s==="isNot"||s==="in"||s==="notIn"){const e=A(i,y[0],r),f=y.map($).join(","),g=s==="is"?y.length>1?`${e} IN (${f})`:`${e} = ${$(y[0])}`:s==="isNot"?y.length>1?`${e} NOT IN (${f})`:`${e} <> ${$(y[0])}`:s==="in"?`${e} IN (${f})`:`${e} NOT IN (${f})`;c+=(a?" AND ":"")+g,a=!0;continue}for(const e of y){const f=A(i,e,r),g=s==="gt"?`${f} > ${$(e)}`:s==="gte"?`${f} >= ${$(e)}`:s==="lt"?`${f} < ${$(e)}`:s==="lte"?`${f} <= ${$(e)}`:`${f} LIKE ${$(e)}`;c+=(a?" AND ":"")+g,a=!0}}}}return a?`(${c})`:""}function k(n,r){if(!n)return"";const t=T(n,void 0,r);return t?`WHERE ${t}`:""}export{T as getWhere,k as getWhereQuery};
@@ -1 +1 @@
1
- import{useCallback as f,useLayoutEffect as P,useReducer as R,useRef as D}from"react";import{DEFAULT_PAGE_SIZE as O}from"./table";const T=1e4;function K(i,w={},k=[]){const{select:m,pageSize:q=O}=w,e=D(null),[,I]=R(c=>c+1,0),n=D(new Map),x=D(null),L=f(()=>{const{select:c,...l}=w;x.current=i.search({select:(o,a)=>({doc:o,meta:a}),...l})},[i,...k]),d=f(()=>{e.current=[],n.current.clear(),L()},[L]),y=f(async c=>{e.current===null&&(e.current=[]),c===!0&&d();const{current:l}=x;if(!l)return!0;let o=!1;for(let a=0;a<q;a++){const s=await l.next();if(s.done){x.current=null,o=!0;break}if(n.current.has(s.value.meta.key)){a+=-1;continue}e.current.push(m?m(s.value.doc):s.value.doc),n.current.set(s.value.meta.key,e.current.length-1)}return e.current=[...e.current],o},[]),p=f(async()=>{const c=await y(!1);return I(),c},[y]);P(()=>{const c=i.subscribe(async l=>{const{mutations:o,removedAll:a}=l;if(a&&d(),!o)return;const s=e.current?.length??0;let g=s,h=!1;const S=new Set;for(const u of o){const{key:r,op:b}=u;switch(b){case"insert":{g+=1;break}case"delete":{if(e.current&&e.current.length>0&&n.current.has(r)){const t=n.current.get(r);if(t===void 0)break;S.add(t),h=!0}break}case"update":{if(n.current.has(r)){const t=n.current.get(r);t!==void 0&&e.current&&(e.current[t]=await i.get(r,m),e.current=[...e.current],h=!0)}else{const t=await i.get(r,m);t&&(e.current=[...e.current??[],t],n.current.set(r,e.current.length-1),h=!0)}break}}}if(S.size>0&&e.current&&e.current.length>0){const u=new Map;e.current=e.current?.filter((b,t)=>!S.has(t));let r=0;for(const[b,t]of n.current)S.has(t)||(u.set(b,r),r++);n.current=u}const A=s!==g;if(A||h){if(A){await y(!0);let u=0;for(;(e.current?.length??0)<g&&u<T;)await y(!1),u++;u===T&&console.warn("Reached maximum iterations in fillNextPage loop. Possible duplicate or data issue.")}I()}});return()=>{c()}},[i]),P(()=>{d(),p()},k);const v=f(async()=>{d(),await p()},[p,d]);return[e.current,{nextPage:p,reset:v,keysIndex:n.current}]}export{K as useSqliteValue};
1
+ import{useCallback as f,useLayoutEffect as P,useReducer as _,useRef as D}from"react";import{DEFAULT_PAGE_SIZE as C}from"./table";import{shallow as E}from"../utils/shallow";const T=1e4;function z(a,w={},I=[]){const{select:m,pageSize:q=C}=w,e=D(null),[,k]=_(c=>c+1,0),n=D(new Map),x=D(null),A=f(()=>{const{select:c,...i}=w;x.current=a.search({select:(u,l)=>({doc:u,meta:l}),...i})},[a,...I]),d=f(()=>{e.current=[],n.current.clear(),A()},[A]),y=f(async c=>{e.current===null&&(e.current=[]),c===!0&&d();const{current:i}=x;if(!i)return!0;let u=!1;for(let l=0;l<q;l++){const o=await i.next();if(o.done){x.current=null,u=!0;break}n.current.has(o.value.meta.key)||(e.current.push(m?m(o.value.doc):o.value.doc),n.current.set(o.value.meta.key,e.current.length-1))}return e.current=[...e.current],u},[]),p=f(async()=>{const c=await y(!1);return k(),c},[y]);P(()=>{const c=a.subscribe(async i=>{const{mutations:u,removedAll:l}=i;if(l&&d(),!u)return;const o=e.current?.length??0;let g=o,h=!1;const S=new Set;for(const s of u){const{key:r,op:b}=s;switch(b){case"insert":{g+=1;break}case"delete":{if(e.current&&e.current.length>0&&n.current.has(r)){const t=n.current.get(r);if(t===void 0)break;S.add(t),h=!0}break}case"update":{if(n.current.has(r)){const t=n.current.get(r);if(t!==void 0&&e.current){const v=await a.get(r,m),O=e.current[t];E(O,v)||(e.current[t]=v,e.current=[...e.current],h=!0)}}else{const t=await a.get(r,m);t&&(e.current=[...e.current??[],t],n.current.set(r,e.current.length-1),h=!0)}break}}}if(S.size>0&&e.current&&e.current.length>0){const s=new Map;e.current=e.current?.filter((b,t)=>!S.has(t));let r=0;for(const[b,t]of n.current)S.has(t)||(s.set(b,r),r++);n.current=s}const L=o!==g;if(L||h){if(L){await y(!0);let s=0;for(;(e.current?.length??0)<g&&s<T;)await y(!1),s++;s===T&&console.warn("Reached maximum iterations in fillNextPage loop. Possible duplicate or data issue.")}k()}});return()=>{c()}},[a]),P(()=>{d(),p()},I);const R=f(async()=>{d(),await p()},[p,d]);return[e.current,{nextPage:p,reset:R,keysIndex:n.current}]}export{z as useSqliteValue};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muya",
3
- "version": "2.4.92",
3
+ "version": "2.4.94",
4
4
  "author": "samuel.gjabel@gmail.com",
5
5
  "repository": "https://github.com/samuelgjabel/muya",
6
6
  "main": "cjs/index.js",
@@ -4,6 +4,7 @@ import { useSqliteValue } from '../use-sqlite'
4
4
  import { waitFor } from '@testing-library/react'
5
5
  import { bunMemoryBackend } from '../table/bun-backend'
6
6
  import { StrictMode, Suspense } from 'react'
7
+ import type { Key } from '../table'
7
8
 
8
9
  const backend = bunMemoryBackend()
9
10
 
@@ -428,4 +429,209 @@ describe('use-sqlite edge cases', () => {
428
429
  ])
429
430
  })
430
431
  })
432
+
433
+ it('should handle rapid consecutive updates without losing state', async () => {
434
+ const sql = createSqliteState<Person>({ backend, tableName: 'RaceState', key: 'id' })
435
+
436
+ // Insert initial document
437
+ await sql.set({ id: '1', name: 'Alice', age: 30 })
438
+
439
+ const { result } = renderHook(() => useSqliteValue(sql, {}, []))
440
+
441
+ await waitFor(() => {
442
+ expect(result.current[0]).toEqual([{ id: '1', name: 'Alice', age: 30 }])
443
+ })
444
+
445
+ // Now simulate fast consecutive updates to the same key
446
+ // These will hit the subscription handler while it's still awaiting `state.get`
447
+ await Promise.all([
448
+ sql.set({ id: '1', name: 'AliceV2', age: 31 }),
449
+ sql.set({ id: '1', name: 'AliceV3', age: 32 }),
450
+ sql.set({ id: '1', name: 'AliceV4', age: 33 }),
451
+ ])
452
+
453
+ // Wait for hook to stabilize
454
+ await waitFor(() => {
455
+ // eslint-disable-next-line prefer-destructuring, unicorn/prevent-abbreviations
456
+ const docs = result.current[0]
457
+ expect(docs?.length).toBe(1)
458
+ // Expect the *latest* update to win
459
+ expect(docs?.[0]).toEqual({ id: '1', name: 'AliceV4', age: 33 })
460
+ })
461
+ })
462
+
463
+ it('should not overwrite newer updates with stale state.get results', async () => {
464
+ const sql = createSqliteState<Person>({ backend, tableName: 'RaceFailState', key: 'id' })
465
+ await sql.set({ id: '1', name: 'Initial', age: 20 })
466
+
467
+ // Delay the first get call artificially to simulate slow DB
468
+ let callCount = 0
469
+ const originalGet = sql.get
470
+ // @ts-expect-error - We mocking the get method for testing
471
+ sql.get = async (key: Key, selector: ((document: Person) => Person) | undefined) => {
472
+ callCount++
473
+ if (callCount === 1) {
474
+ // Simulate slow resolution for first update
475
+ await new Promise((resolve) => setTimeout(resolve, 50))
476
+ }
477
+ return originalGet(key, selector)
478
+ }
479
+
480
+ const { result } = renderHook(() => useSqliteValue(sql, {}, []))
481
+
482
+ await waitFor(() => {
483
+ expect(result.current[0]).toEqual([{ id: '1', name: 'Initial', age: 20 }])
484
+ })
485
+
486
+ // Trigger two consecutive updates
487
+ await sql.set({ id: '1', name: 'AliceV1', age: 21 })
488
+ await sql.set({ id: '1', name: 'AliceV2', age: 22 })
489
+
490
+ // Wait for hook to stabilize
491
+ await waitFor(() => {
492
+ // eslint-disable-next-line prefer-destructuring, unicorn/prevent-abbreviations
493
+ const docs = result.current[0]
494
+ expect(docs?.length).toBe(1)
495
+ // 🔥 Correct behavior: should end with the *latest* version (AliceV2)
496
+ // ❌ Buggy behavior: may still show AliceV1 because the delayed get resolves last
497
+ expect(docs?.[0]).toEqual({ id: '1', name: 'AliceV2', age: 22 })
498
+ })
499
+ })
500
+ it('should reset correctly during pagination', async () => {
501
+ const sql = createSqliteState<Person>({ backend, tableName: 'ResetMid', key: 'id' })
502
+ const people = Array.from({ length: 200 }, (_, index) => ({ id: `${index + 1}`, name: `P${index + 1}`, age: index }))
503
+ await sql.batchSet(people)
504
+
505
+ const { result } = renderHook(() => useSqliteValue(sql, { pageSize: 50 }, []))
506
+
507
+ await waitFor(() => {
508
+ expect(result.current[0]?.length).toBe(50)
509
+ })
510
+
511
+ // Load next page
512
+ await act(async () => {
513
+ await result.current[1].nextPage()
514
+ })
515
+ expect(result.current[0]?.length).toBe(100)
516
+
517
+ // Reset
518
+ await act(async () => {
519
+ await result.current[1].reset()
520
+ })
521
+ await waitFor(() => {
522
+ // Should go back to first page only
523
+ expect(result.current[0]?.length).toBe(50)
524
+ expect(result.current[0]?.[0]?.id).toBe('1')
525
+ })
526
+ })
527
+ it('should overwrite duplicate keys instead of duplicating items', async () => {
528
+ const sql = createSqliteState<Person>({ backend, tableName: 'DupTest', key: 'id' })
529
+ await sql.set({ id: '1', name: 'Alice', age: 30 })
530
+ await sql.set({ id: '1', name: 'Alice2', age: 35 }) // overwrite
531
+
532
+ const { result } = renderHook(() => useSqliteValue(sql, {}, []))
533
+
534
+ await waitFor(() => {
535
+ expect(result.current[0]).toEqual([{ id: '1', name: 'Alice2', age: 35 }])
536
+ })
537
+ })
538
+ it('should not update after unmount', async () => {
539
+ const sql = createSqliteState<Person>({ backend, tableName: 'UnmountTest', key: 'id' })
540
+ const { unmount, result } = renderHook(() => useSqliteValue(sql, {}, []))
541
+
542
+ unmount()
543
+ await sql.set({ id: '1', name: 'ShouldNotAppear', age: 99 })
544
+
545
+ // wait briefly to give subscription a chance
546
+ await new Promise((r) => setTimeout(r, 20))
547
+
548
+ expect(result.current[0]).toBeNull() // no state change after unmount
549
+ })
550
+ it('should restart iterator when sortBy changes', async () => {
551
+ const sql = createSqliteState<Person>({ backend, tableName: 'SortChange', key: 'id' })
552
+ await sql.batchSet([
553
+ { id: '1', name: 'A', age: 40 },
554
+ { id: '2', name: 'B', age: 20 },
555
+ ])
556
+
557
+ const { result, rerender } = renderHook(({ sortBy }) => useSqliteValue(sql, { sortBy }, [sortBy]), {
558
+ initialProps: { sortBy: 'age' as keyof Person },
559
+ })
560
+
561
+ await waitFor(() => {
562
+ expect(result.current[0]?.[0]?.age).toBe(20)
563
+ })
564
+
565
+ act(() => {
566
+ rerender({ sortBy: 'name' })
567
+ })
568
+
569
+ await waitFor(() => {
570
+ expect(result.current[0]?.[0]?.name).toBe('A') // sorted by name asc
571
+ })
572
+ })
573
+
574
+ it('should return done when nextPage is called on empty table', async () => {
575
+ const sql = createSqliteState<Person>({ backend, tableName: 'EmptyNext', key: 'id' })
576
+ const { result } = renderHook(() => useSqliteValue(sql, {}, []))
577
+
578
+ // @ts-expect-error - Testing internal method
579
+ const isDone = await act(result.current[1].nextPage)
580
+ expect(isDone).toBe(true)
581
+ expect(result.current[0]).toEqual([])
582
+ })
583
+
584
+ it('should handle deletion of non-visible item gracefully', async () => {
585
+ const sql = createSqliteState<Person>({ backend, tableName: 'DeleteNonVisible', key: 'id' })
586
+ const people = Array.from({ length: 200 }, (_, index) => ({ id: `${index + 1}`, name: `P${index + 1}`, age: index }))
587
+ await sql.batchSet(people)
588
+ let renderCount = 0
589
+ const { result } = renderHook(() => {
590
+ renderCount++
591
+ return useSqliteValue(sql, { pageSize: 50 }, [])
592
+ })
593
+ await waitFor(() => {
594
+ expect(renderCount).toBe(2) // initial + after load
595
+ expect(result.current[0]?.length).toBe(50)
596
+ })
597
+
598
+ // Delete item outside current page
599
+ await act(async () => {
600
+ await sql.delete('150')
601
+ })
602
+
603
+ await waitFor(() => {
604
+ expect(renderCount).toBe(2) // no re-render
605
+ expect(result.current[0]?.length).toBe(50) // unchanged page size
606
+ })
607
+ })
608
+ it('should not rerender when using select if the selected value does not change', async () => {
609
+ const sql = createSqliteState<Person>({ backend, tableName: 'SelectNoReRender', key: 'id' })
610
+ await sql.set({ id: '1', name: 'Alice', age: 30 })
611
+
612
+ let renders = 0
613
+ const { result } = renderHook(() => {
614
+ renders++
615
+ // Only project `name`
616
+ return useSqliteValue(sql, { select: (p) => p.name }, [])
617
+ })
618
+
619
+ await waitFor(() => {
620
+ expect(result.current[0]).toEqual(['Alice'])
621
+ expect(renders).toBe(2) // initial + after first load
622
+ })
623
+
624
+ // Update age (not part of select projection)
625
+ await act(async () => {
626
+ await sql.set({ id: '1', name: 'Alice', age: 31 })
627
+ })
628
+
629
+ // Wait a bit to let subscription flush
630
+ await new Promise((r) => setTimeout(r, 20))
631
+
632
+ // ❌ Buggy: renders increments again, even though "Alice" didn't change
633
+ // ✅ Expected: still 2 renders
634
+ expect(result.current[0]).toEqual(['Alice'])
635
+ expect(renders).toBe(2)
636
+ })
431
637
  })
@@ -0,0 +1,234 @@
1
+ import { createTable } from '../table'
2
+ import { bunMemoryBackend } from '../table/bun-backend'
3
+ import { type Where } from '../table/where'
4
+
5
+ describe('where clauses', () => {
6
+ const backend = bunMemoryBackend()
7
+
8
+ it('should handle where where array of conditions', async () => {
9
+ const tableNestedOptional = await createTable<{ id: string; content?: string }>({
10
+ backend,
11
+ tableName: 'TestTableNestedOptional',
12
+ key: 'id',
13
+ indexes: ['content'],
14
+ })
15
+
16
+ await tableNestedOptional.set({ id: '1', content: 'The quick brown fox' })
17
+ await tableNestedOptional.set({ id: '2', content: 'The jumps over the lazy dog' })
18
+ await tableNestedOptional.set({ id: '3' }) // No `content` field
19
+
20
+ const results: { id: string; content?: string }[] = []
21
+ for await (const doc of tableNestedOptional.search({
22
+ where: { content: { like: ['The%'] } },
23
+ })) {
24
+ results.push(doc)
25
+ }
26
+ expect(results.length).toBe(2)
27
+ expect(results[0].id).toBe('1')
28
+ })
29
+ it('should create nested index for optional nested fields', async () => {
30
+ type TestDoc = { id: string; info?: { content?: string } }
31
+ const tableNestedOptional = await createTable<TestDoc>({
32
+ backend,
33
+ tableName: 'TestTableNestedOptional',
34
+ key: 'id',
35
+ indexes: ['fts:info.content'],
36
+ })
37
+
38
+ await tableNestedOptional.set({ id: '1', info: { content: 'The quick brown fox' } })
39
+ await tableNestedOptional.set({ id: '2', info: { content: 'jumps over the lazy dog' } })
40
+ await tableNestedOptional.set({ id: '3' }) // No `info` field
41
+
42
+ const results: TestDoc[] = []
43
+ for await (const doc of tableNestedOptional.search({
44
+ where: { info: { content: { like: ['The%'] } } },
45
+ })) {
46
+ results.push(doc)
47
+ }
48
+ expect(results.length).toBe(1)
49
+ expect(results[0].id).toBe('1')
50
+
51
+ const results2: TestDoc[] = []
52
+ for await (const doc of tableNestedOptional.search({
53
+ where: { OR: [{ info: { content: { like: ['The%'] } } }, { info: { content: { like: ['jumps%'] } } }] },
54
+ })) {
55
+ results2.push(doc)
56
+ }
57
+ expect(results2.length).toBe(2)
58
+
59
+ const results3: TestDoc[] = []
60
+ for await (const doc of tableNestedOptional.search({
61
+ where: { info: { content: 'nonexistent' } },
62
+ })) {
63
+ results3.push(doc)
64
+ }
65
+ expect(results3.length).toBe(0)
66
+ })
67
+ it('should handle FTS queries', async () => {
68
+ const tableFts = await createTable<{ id: string; content: string }>({
69
+ backend,
70
+ tableName: 'TestTableFts',
71
+ key: 'id',
72
+ indexes: ['fts:content'],
73
+ })
74
+
75
+ await tableFts.set({ id: '1', content: 'The quick brown fox' })
76
+ await tableFts.set({ id: '2', content: 'Jumps over the lazy dog' })
77
+ await tableFts.set({ id: '3', content: 'Another document' })
78
+
79
+ const results: { id: string; content: string }[] = []
80
+ for await (const doc of tableFts.search({
81
+ where: { content: { fts: 'quick' } },
82
+ })) {
83
+ results.push(doc)
84
+ }
85
+ expect(results.length).toBe(1)
86
+ expect(results[0].id).toBe('1')
87
+ })
88
+
89
+ it('should handle nested where conditions', async () => {
90
+ const tableNested = await createTable<{ id: string; info: { content: string } }>({
91
+ backend,
92
+ tableName: 'TestTableNested',
93
+ key: 'id',
94
+ indexes: ['info.content'],
95
+ })
96
+
97
+ await tableNested.set({ id: '1', info: { content: 'Nested quick brown fox' } })
98
+ await tableNested.set({ id: '2', info: { content: 'Nested jumps over the lazy dog' } })
99
+
100
+ const results: { id: string; info: { content: string } }[] = []
101
+ for await (const doc of tableNested.search({
102
+ where: { info: { content: { like: 'Nested%' } } },
103
+ })) {
104
+ results.push(doc)
105
+ }
106
+ expect(results.length).toBe(2)
107
+ })
108
+
109
+ it('should handle complex operators', async () => {
110
+ const tableComplex = await createTable<{ id: string; value: number }>({
111
+ backend,
112
+ tableName: 'TestTableComplex',
113
+ key: 'id',
114
+ indexes: ['value'],
115
+ })
116
+
117
+ await tableComplex.set({ id: '1', value: 10 })
118
+ await tableComplex.set({ id: '2', value: 20 })
119
+ await tableComplex.set({ id: '3', value: 30 })
120
+
121
+ const results: { id: string; value: number }[] = []
122
+ for await (const doc of tableComplex.search({
123
+ where: { value: { gt: 15, lt: 25 } },
124
+ sortBy: 'value',
125
+ })) {
126
+ results.push(doc)
127
+ }
128
+ expect(results.length).toBe(1)
129
+ expect(results[0].id).toBe('2')
130
+ })
131
+
132
+ it('should handle NOT conditions', async () => {
133
+ const tableNot = await createTable<{ id: string; value: string }>({
134
+ backend,
135
+ tableName: 'TestTableNot',
136
+ key: 'id',
137
+ indexes: ['value'],
138
+ })
139
+
140
+ await tableNot.set({ id: '1', value: 'apple' })
141
+ await tableNot.set({ id: '2', value: 'banana' })
142
+ await tableNot.set({ id: '3', value: 'cherry' })
143
+
144
+ const results: { id: string; value: string }[] = []
145
+ for await (const doc of tableNot.search({
146
+ where: { NOT: { value: 'banana' } },
147
+ })) {
148
+ results.push(doc)
149
+ }
150
+ expect(results.length).toBe(2)
151
+ expect(results.map((doc) => doc.value)).toEqual(['apple', 'cherry'])
152
+ })
153
+
154
+ it('should handle AND conditions', async () => {
155
+ const tableAnd = await createTable<{ id: string; category: string; price: number }>({
156
+ backend,
157
+ tableName: 'TestTableAnd',
158
+ key: 'id',
159
+ indexes: ['category', 'price'],
160
+ })
161
+
162
+ await tableAnd.set({ id: '1', category: 'fruit', price: 10 })
163
+ await tableAnd.set({ id: '2', category: 'fruit', price: 20 })
164
+ await tableAnd.set({ id: '3', category: 'vegetable', price: 15 })
165
+
166
+ const results: { id: string; category: string; price: number }[] = []
167
+ for await (const doc of tableAnd.search({
168
+ where: { AND: [{ category: 'fruit' }, { price: { lt: 15 } }] },
169
+ })) {
170
+ results.push(doc)
171
+ }
172
+ expect(results.length).toBe(1)
173
+ expect(results[0].id).toBe('1')
174
+ })
175
+
176
+ it('should handle OR conditions', async () => {
177
+ const tableOr = await createTable<{ id: string; type: string }>({
178
+ backend,
179
+ tableName: 'TestTableOr',
180
+ key: 'id',
181
+ indexes: ['type'],
182
+ })
183
+
184
+ await tableOr.set({ id: '1', type: 'A' })
185
+ await tableOr.set({ id: '2', type: 'B' })
186
+ await tableOr.set({ id: '3', type: 'C' })
187
+
188
+ const results: { id: string; type: string }[] = []
189
+ for await (const doc of tableOr.search({
190
+ where: { OR: [{ type: 'A' }, { type: 'C' }] },
191
+ })) {
192
+ results.push(doc)
193
+ }
194
+ expect(results.length).toBe(2)
195
+ expect(results.map((doc) => doc.type)).toEqual(['A', 'C'])
196
+ })
197
+
198
+ it('should handle nested AND/OR/NOT conditions', async () => {
199
+ const tableNestedLogic = await createTable<{ id: string; category: string; price: number }>({
200
+ backend,
201
+ tableName: 'TestTableNestedLogic',
202
+ key: 'id',
203
+ indexes: ['category', 'price'],
204
+ })
205
+
206
+ await tableNestedLogic.set({ id: '1', category: 'fruit', price: 10 })
207
+ await tableNestedLogic.set({ id: '2', category: 'fruit', price: 20 })
208
+ await tableNestedLogic.set({ id: '3', category: 'fruit', price: 15 })
209
+
210
+ const whereClause: Where<{ category: string; price: number }> = {
211
+ AND: [
212
+ { category: { is: 'fruit' } },
213
+ {
214
+ OR: [{ price: { lt: 15 } }, { price: { is: 15 } }],
215
+ },
216
+ ],
217
+ }
218
+
219
+ const allData: { id: string; category: string; price: number }[] = []
220
+ for await (const doc of tableNestedLogic.search({})) {
221
+ allData.push(doc)
222
+ }
223
+ expect(allData.length).toBe(3)
224
+
225
+ const results: { id: string; category: string; price: number }[] = []
226
+ for await (const doc of tableNestedLogic.search({
227
+ where: whereClause,
228
+ })) {
229
+ results.push(doc)
230
+ }
231
+
232
+ expect(results.length).toBe(2)
233
+ })
234
+ })
@@ -20,14 +20,16 @@ export type DotPrefix<T extends string> = T extends '' ? '' : `.${T}`
20
20
 
21
21
  type Previous = [never, 0, 1, 2, 3, 4, 5]
22
22
 
23
- export type DotPath<T, D extends number = 5> = [D] extends [never]
23
+ type DotPathRaw<T, D extends number = 5> = [D] extends [never]
24
24
  ? never
25
25
  : T extends object
26
26
  ? {
27
- [K in Extract<keyof T, string>]: T[K] extends object ? K | `${K}.${DotPath<T[K], Previous[D]>}` : K
27
+ [K in Extract<keyof T, string>]: T[K] extends object ? K | `${K}.${DotPathRaw<T[K], Previous[D]>}` : K
28
28
  }[Extract<keyof T, string>]
29
29
  : never
30
30
 
31
+ export type DotPath<T> = DotPathRaw<MakeAllFieldAsRequired<T>>
32
+
31
33
  // Built-in FTS5 tokenizers
32
34
  export type FtsTokenizer =
33
35
  | 'porter' // English stemming
@@ -81,3 +83,7 @@ export interface Table<Document extends DocType> extends DbNotGeneric {
81
83
  readonly deleteBy: (where: Where<Document>) => Promise<MutationResult[]>
82
84
  readonly clear: () => Promise<void>
83
85
  }
86
+
87
+ export type MakeAllFieldAsRequired<T> = {
88
+ [K in keyof T]-?: T[K] extends object ? MakeAllFieldAsRequired<T[K]> : T[K]
89
+ }
@@ -4,6 +4,8 @@
4
4
  /* eslint-disable sonarjs/no-nested-conditional */
5
5
  /* eslint-disable sonarjs/cognitive-complexity */
6
6
 
7
+ import type { MakeAllFieldAsRequired } from './table.types'
8
+
7
9
  // -------------------------------------------------------------
8
10
  // Condition operators for each field
9
11
  // -------------------------------------------------------------
@@ -20,21 +22,22 @@ interface Condition<T> {
20
22
  readonly fts?: string | string[] // 🔥 NEW
21
23
  }
22
24
 
23
- // -------------------------------------------------------------
24
- // Where type: recursive object with operators or nested fields
25
- // -------------------------------------------------------------
26
- export type Where<T extends Record<string, unknown>> =
25
+ type WhereRaw<T extends Record<string, unknown>> =
27
26
  | {
28
27
  [K in keyof T]?: T[K] extends Record<string, unknown>
29
- ? Where<T[K]> // nested object
28
+ ? WhereRaw<T[K]> // nested object
30
29
  : Condition<T[K]> | T[K] | T[K][]
31
30
  }
32
31
  | {
33
- readonly AND?: Array<Where<T>>
34
- readonly OR?: Array<Where<T>>
35
- readonly NOT?: Where<T>
32
+ readonly AND?: Array<WhereRaw<T>>
33
+ readonly OR?: Array<WhereRaw<T>>
34
+ readonly NOT?: WhereRaw<T>
36
35
  }
37
36
 
37
+ // -------------------------------------------------------------
38
+ // Where type: recursive object with operators or nested fields
39
+ // -------------------------------------------------------------
40
+ export type Where<T extends Record<string, unknown>> = WhereRaw<MakeAllFieldAsRequired<T>>
38
41
  /**
39
42
  * Inline a value for SQL query, with proper escaping for strings
40
43
  * @param value The value to inline
@@ -3,6 +3,7 @@ import { useCallback, useLayoutEffect, useReducer, useRef, type DependencyList }
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
+ import { shallow } from '../utils/shallow'
6
7
  const MAX_ITERATIONS = 10_000
7
8
 
8
9
  export interface SqLiteActions {
@@ -54,7 +55,7 @@ export function useSqliteValue<Document extends DocType, Selected = Document>(
54
55
  // eslint-disable-next-line react-hooks/exhaustive-deps
55
56
  }, [state, ...deps])
56
57
 
57
- const reset = useCallback(() => {
58
+ const resetDataAndUpdateIterator = useCallback(() => {
58
59
  itemsRef.current = []
59
60
  keysIndex.current.clear()
60
61
  updateIterator()
@@ -65,7 +66,7 @@ export function useSqliteValue<Document extends DocType, Selected = Document>(
65
66
  itemsRef.current = []
66
67
  }
67
68
  if (shouldReset === true) {
68
- reset()
69
+ resetDataAndUpdateIterator()
69
70
  }
70
71
 
71
72
  const { current: iterator } = iteratorRef
@@ -82,8 +83,6 @@ export function useSqliteValue<Document extends DocType, Selected = Document>(
82
83
  break
83
84
  }
84
85
  if (keysIndex.current.has(result.value.meta.key)) {
85
- // eslint-disable-next-line sonarjs/updated-loop-counter
86
- index += -1
87
86
  continue
88
87
  }
89
88
  itemsRef.current.push(select ? select(result.value.doc) : (result.value.doc as unknown as Selected))
@@ -104,7 +103,7 @@ export function useSqliteValue<Document extends DocType, Selected = Document>(
104
103
  const unsubscribe = state.subscribe(async (item) => {
105
104
  const { mutations, removedAll } = item
106
105
  if (removedAll) {
107
- reset()
106
+ resetDataAndUpdateIterator()
108
107
  }
109
108
  if (!mutations) {
110
109
  return
@@ -134,9 +133,15 @@ export function useSqliteValue<Document extends DocType, Selected = Document>(
134
133
  if (keysIndex.current.has(key)) {
135
134
  const index = keysIndex.current.get(key)
136
135
  if (index !== undefined && itemsRef.current) {
137
- itemsRef.current[index] = (await state.get(key, select)) as Selected
138
- itemsRef.current = [...itemsRef.current]
139
- hasUpdate = true
136
+ const newItem = (await state.get(key, select)) as Selected
137
+ const previousItem = itemsRef.current[index]
138
+
139
+ // 🆕 Only update & rerender if shallow comparison fails
140
+ if (!shallow(previousItem, newItem)) {
141
+ itemsRef.current[index] = newItem
142
+ itemsRef.current = [...itemsRef.current]
143
+ hasUpdate = true
144
+ }
140
145
  }
141
146
  } else {
142
147
  // Handle updates to non-visible items
@@ -193,15 +198,15 @@ export function useSqliteValue<Document extends DocType, Selected = Document>(
193
198
  }, [state])
194
199
 
195
200
  useLayoutEffect(() => {
196
- reset()
201
+ resetDataAndUpdateIterator()
197
202
  nextPage()
198
203
  // eslint-disable-next-line react-hooks/exhaustive-deps
199
204
  }, deps)
200
205
 
201
206
  const resetCb = useCallback(async () => {
202
- reset()
207
+ resetDataAndUpdateIterator()
203
208
  await nextPage()
204
- }, [nextPage, reset])
209
+ }, [nextPage, resetDataAndUpdateIterator])
205
210
 
206
211
  return [itemsRef.current, { nextPage, reset: resetCb, keysIndex: keysIndex.current }] as [
207
212
  (undefined extends Selected ? Document[] : Selected[]) | null,
@@ -15,9 +15,10 @@ export interface SqlSeachOptions<Document extends DocType> {
15
15
  }
16
16
  export type DotPrefix<T extends string> = T extends '' ? '' : `.${T}`;
17
17
  type Previous = [never, 0, 1, 2, 3, 4, 5];
18
- export type DotPath<T, D extends number = 5> = [D] extends [never] ? never : T extends object ? {
19
- [K in Extract<keyof T, string>]: T[K] extends object ? K | `${K}.${DotPath<T[K], Previous[D]>}` : K;
18
+ type DotPathRaw<T, D extends number = 5> = [D] extends [never] ? never : T extends object ? {
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
+ export type DotPath<T> = DotPathRaw<MakeAllFieldAsRequired<T>>;
21
22
  export type FtsTokenizer = 'porter' | 'simple' | 'icu' | 'unicode61' | FtsTokenizerOptions;
22
23
  export interface FtsType<Document extends DocType> {
23
24
  readonly type: 'fts';
@@ -59,4 +60,7 @@ export interface Table<Document extends DocType> extends DbNotGeneric {
59
60
  readonly deleteBy: (where: Where<Document>) => Promise<MutationResult[]>;
60
61
  readonly clear: () => Promise<void>;
61
62
  }
63
+ export type MakeAllFieldAsRequired<T> = {
64
+ [K in keyof T]-?: T[K] extends object ? MakeAllFieldAsRequired<T[K]> : T[K];
65
+ };
62
66
  export {};
@@ -1,3 +1,4 @@
1
+ import type { MakeAllFieldAsRequired } from './table.types';
1
2
  interface Condition<T> {
2
3
  readonly is?: T | T[];
3
4
  readonly isNot?: T | T[];
@@ -10,13 +11,14 @@ interface Condition<T> {
10
11
  readonly like?: T | T[];
11
12
  readonly fts?: string | string[];
12
13
  }
13
- export type Where<T extends Record<string, unknown>> = {
14
- [K in keyof T]?: T[K] extends Record<string, unknown> ? Where<T[K]> : Condition<T[K]> | T[K] | T[K][];
14
+ type WhereRaw<T extends Record<string, unknown>> = {
15
+ [K in keyof T]?: T[K] extends Record<string, unknown> ? WhereRaw<T[K]> : Condition<T[K]> | T[K] | T[K][];
15
16
  } | {
16
- readonly AND?: Array<Where<T>>;
17
- readonly OR?: Array<Where<T>>;
18
- readonly NOT?: Where<T>;
17
+ readonly AND?: Array<WhereRaw<T>>;
18
+ readonly OR?: Array<WhereRaw<T>>;
19
+ readonly NOT?: WhereRaw<T>;
19
20
  };
21
+ export type Where<T extends Record<string, unknown>> = WhereRaw<MakeAllFieldAsRequired<T>>;
20
22
  /**
21
23
  * Write SQL WHERE clause from a Where object
22
24
  * @param where The Where object defining the conditions