muya 2.4.6 → 2.4.8

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.
@@ -1 +1 @@
1
- import{STATE_SCHEDULER as l}from"../create";import{getId as m}from"../utils/id";import{createTable as d}from"./table/table";function D(u){let s;async function a(){if(!s){const{backend:e,...n}=u,t=e instanceof Promise?await e:e;s=await d({backend:t,...n})}return s}const i=m();l.add(i,{onScheduleDone(e){if(!e)return;const n=e,t={};for(const c of n)c.removedAll&&(t.removedAll=!0),c.mutations&&(t.mutations||(t.mutations=[]),t.mutations.push(...c.mutations));for(const c of r)c(t)}});function o(e){l.schedule(i,e)}const r=new Set;return{subscribe(e){return r.add(e),()=>r.delete(e)},clear(){s?.clear(),o({removedAll:!0})},async set(e){const t=await(await a()).set(e);return o({mutations:[t]}),t},async batchSet(e){const t=await(await a()).batchSet(e);return o({mutations:t}),t},async delete(e){const t=await(await a()).delete(e);return t&&o({mutations:[t]}),t},async deleteBy(e){const t=await(await a()).deleteBy(e);return o({mutations:t}),t},async get(e,n){return(await a()).get(e,n)},async*search(e={}){const n=await a();for await(const t of n.search(e))yield t},async count(e){return await(await a()).count(e)}}}export{D as createSqliteState};
1
+ import{STATE_SCHEDULER as l}from"../create";import{getId as m}from"../utils/id";import{createTable as d}from"./table/table";function D(u){let s;async function a(){if(!s){const{backend:e,...n}=u,t=e instanceof Promise?await e:e;s=await d({backend:t,...n})}return s}const i=m();l.add(i,{onScheduleDone(e){if(!e)return;const n=e,t={};for(const c of n)c.removedAll&&(t.removedAll=!0),c.mutations&&(t.mutations||(t.mutations=[]),t.mutations.push(...c.mutations));for(const c of r)c(t)}});function o(e){l.schedule(i,e)}const r=new Set;return{subscribe(e){return r.add(e),()=>r.delete(e)},async clear(){const e=await a();return o({removedAll:!0}),e.clear()},async set(e){const t=await(await a()).set(e);return o({mutations:[t]}),t},async batchSet(e){const t=await(await a()).batchSet(e);return o({mutations:t}),t},async delete(e){const t=await(await a()).delete(e);return t&&o({mutations:[t]}),t},async deleteBy(e){const t=await(await a()).deleteBy(e);return o({mutations:t}),t},async get(e,n){return(await a()).get(e,n)},async*search(e={}){const n=await a();for await(const t of n.search(e))yield t},async count(e){return await(await a()).count(e)}}}export{D as createSqliteState};
@@ -1,38 +1,38 @@
1
- import{unicodeTokenizer as C}from"./tokenizer";import{getWhereQuery as k}from"./where";const L=500,M=100;function R(l){return"$."+l}function U(l,a){if(!(!l||!a))return a.split(".").reduce((t,y)=>{if(typeof t=="object"&&t!==null&&y in t)return t[y]},l)}async function P(l){const{backend:a,tableName:t,indexes:y,key:A,disablePragmaOptimization:x}=l,u=A!==void 0;x||(await a.execute("PRAGMA journal_mode=WAL;"),await a.execute("PRAGMA synchronous=NORMAL;"),await a.execute("PRAGMA temp_store=MEMORY;"),await a.execute("PRAGMA cache_size=-20000;")),u?await a.execute(`
1
+ import{unicodeTokenizer as _}from"./tokenizer";import{getWhereQuery as g}from"./where";const x=500,C=100;function m(l){return"$."+l}function M(l,s){if(!(!l||!s))return s.split(".").reduce((t,y)=>{if(typeof t=="object"&&t!==null&&y in t)return t[y]},l)}async function G(l){const{backend:s,tableName:t,indexes:y,key:$,disablePragmaOptimization:L}=l,u=$!==void 0;L||(await s.execute("PRAGMA journal_mode=WAL;"),await s.execute("PRAGMA synchronous=NORMAL;"),await s.execute("PRAGMA temp_store=MEMORY;"),await s.execute("PRAGMA cache_size=-20000;")),u?await s.execute(`
2
2
  CREATE TABLE IF NOT EXISTS ${t} (
3
3
  key TEXT PRIMARY KEY,
4
4
  data TEXT NOT NULL
5
5
  );
6
- `):await a.execute(`
6
+ `):await s.execute(`
7
7
  CREATE TABLE IF NOT EXISTS ${t} (
8
8
  data TEXT NOT NULL
9
9
  );
10
- `);let d;const T=[],f={};for(const e of y??[])if(typeof e=="string"&&e.startsWith("fts:")){const n=e.slice(4),r=n.replaceAll(".","_");T.push(n),f[n]=r}else if(typeof e=="object"&&e.type==="fts"){const n=e.path,r=n.replaceAll(".","_");if(T.push(n),f[n]=r,e.tokenizer){if(!d)d=e.tokenizer;else if(d!==e.tokenizer)throw new Error(`Conflicting FTS tokenizers: already using "${d}", got "${e.tokenizer}"`)}}else{const n=String(e);await a.execute(`CREATE INDEX IF NOT EXISTS idx_${t}_${n.replaceAll(/\W/g,"_")}
11
- ON ${t} (json_extract(data, '${R(n)}'));`)}if(T.length>0){let e;typeof d=="object"?e=C(d):d===void 0?e='"unicode61", "remove_diacritics=1"':e=d;const n=T.map(o=>f[o]).join(", "),r=`
10
+ `);let d;const T=[],f={};for(const e of y??[])if(typeof e=="string"&&e.startsWith("fts:")){const n=e.slice(4),o=n.replaceAll(".","_");T.push(n),f[n]=o}else if(typeof e=="object"&&e.type==="fts"){const n=e.path,o=n.replaceAll(".","_");if(T.push(n),f[n]=o,e.tokenizer){if(!d)d=e.tokenizer;else if(d!==e.tokenizer)throw new Error(`Conflicting FTS tokenizers: already using "${d}", got "${e.tokenizer}"`)}}else{const n=String(e);await s.execute(`CREATE INDEX IF NOT EXISTS idx_${t}_${n.replaceAll(/\W/g,"_")}
11
+ ON ${t} (json_extract(data, '${m(n)}'));`)}if(T.length>0){let e;typeof d=="object"?e=_(d):d===void 0?e='"unicode61", "remove_diacritics=1"':e=d;const n=T.map(r=>f[r]).join(", "),o=`
12
12
  CREATE VIRTUAL TABLE IF NOT EXISTS ${t}_fts
13
13
  USING fts5(${n}, tokenize=${e});
14
- `;await a.execute(r),await a.execute(`
14
+ `;await s.execute(o),await s.execute(`
15
15
  CREATE TRIGGER IF NOT EXISTS ${t}_ai
16
16
  AFTER INSERT ON ${t}
17
17
  BEGIN
18
18
  INSERT INTO ${t}_fts(rowid, ${n})
19
19
  VALUES (
20
20
  new.rowid,
21
- ${T.map(o=>`json_extract(new.data, '${R(o)}')`).join(", ")}
21
+ ${T.map(r=>`json_extract(new.data, '${m(r)}')`).join(", ")}
22
22
  );
23
23
  END;
24
- `),await a.execute(`
24
+ `),await s.execute(`
25
25
  CREATE TRIGGER IF NOT EXISTS ${t}_ad
26
26
  AFTER DELETE ON ${t}
27
27
  BEGIN
28
28
  DELETE FROM ${t}_fts WHERE rowid = old.rowid;
29
29
  END;
30
- `),await a.execute(`
30
+ `),await s.execute(`
31
31
  CREATE TRIGGER IF NOT EXISTS ${t}_au
32
32
  AFTER UPDATE ON ${t}
33
33
  BEGIN
34
34
  UPDATE ${t}_fts
35
- SET ${T.map(o=>`${f[o]}=json_extract(new.data, '${R(o)}')`).join(", ")}
35
+ SET ${T.map(r=>`${f[r]}=json_extract(new.data, '${m(r)}')`).join(", ")}
36
36
  WHERE rowid = old.rowid;
37
37
  END;
38
- `)}function $(e){if(u)return U(e,String(A))}async function b(e){return(await e.select("SELECT changes() AS c"))[0]?.c??0}const g={backend:a,async set(e,n){const r=n??a,o=JSON.stringify(e);if(u){const s=$(e);if(s==null)throw new Error(`Document is missing the configured key "${String(A)}".`);if(await r.execute(`UPDATE ${t} SET data = ? WHERE key = ?`,[o,s]),await b(r)===1)return{key:s,op:"update"};try{return await r.execute(`INSERT INTO ${t} (key, data) VALUES (?, ?)`,[s,o]),{key:s,op:"insert"}}catch{return await r.execute(`UPDATE ${t} SET data = ? WHERE key = ?`,[o,s]),{key:s,op:"update"}}}await r.execute(`INSERT INTO ${t} (data) VALUES (?)`,[o]);const c=(await r.select("SELECT last_insert_rowid() AS id"))[0]?.id;if(typeof c!="number")throw new Error("Failed to retrieve last_insert_rowid()");return{key:c,op:"insert"}},async get(e,n=r=>r){const r=u?"key = ?":"rowid = ?",o=await a.select(`SELECT rowid, data FROM ${t} WHERE ${r}`,[e]);if(o.length===0)return;const{data:E,rowid:c}=o[0],s=JSON.parse(E),i=u?$(s)??c:c;return n(s,{rowId:c,key:i})},async delete(e){const n=u?"key = ?":"rowid = ?";if(await a.execute(`DELETE FROM ${t} WHERE ${n}`,[e]),((await a.select("SELECT changes() AS c"))[0]?.c??0)>0)return{key:e,op:"delete"}},async*search(e={}){const{sortBy:n,order:r="asc",limit:o,offset:E=0,where:c,select:s=w=>w,pageSize:i=M}=e,p=k(c,t),h=`SELECT rowid, data FROM ${t} ${p}`;let S=0,D=E;for(;;){let w=h;n?w+=` ORDER BY json_extract(data, '${R(String(n))}') COLLATE NOCASE ${r.toUpperCase()}`:w+=u?` ORDER BY key COLLATE NOCASE ${r.toUpperCase()}`:` ORDER BY rowid ${r.toUpperCase()}`;const I=o?Math.min(i,o-S):i;w+=` LIMIT ${I} OFFSET ${D}`;const m=await a.select(w);if(m.length===0)break;for(const{rowid:O,data:F}of m){if(o&&S>=o)return;const N=JSON.parse(F),_=u?$(N)??O:O;yield s(N,{rowId:O,key:_}),S++}if(m.length<I||o&&S>=o)break;D+=m.length}},async count(e={}){const n=k(e.where,t),r=`SELECT COUNT(*) as count FROM ${t} ${n}`;return(await a.select(r))[0]?.count??0},async deleteBy(e){const n=k(e,t),r=u?"key":"rowid",o=[];return await a.transaction(async E=>{const c=await E.select(`SELECT ${r} AS k FROM ${t} ${n}`);if(c.length===0)return;const s=c.map(i=>i.k);for(let i=0;i<s.length;i+=L){const p=s.slice(i,i+L),h=p.map(()=>"?").join(",");await E.execute(`DELETE FROM ${t} WHERE ${r} IN (${h})`,p)}for(const i of s)o.push({key:i,op:"delete"})}),o},async clear(){await a.execute(`DELETE FROM ${t}`)},async batchSet(e){const n=[];return await a.transaction(async r=>{for(const o of e){const E=await g.set(o,r);n.push(E)}}),n}};return g}export{M as DEFAULT_PAGE_SIZE,P as createTable,U as getByPath,R as toJsonPath};
38
+ `)}function A(e){if(u)return M(e,String($))}const k={backend:s,async set(e,n){const o=n??s,r=JSON.stringify(e);if(u){const a=A(e);if(a==null)throw new Error(`Document is missing the configured key "${String($)}".`);return(await o.select(`SELECT key FROM ${t} WHERE key = ?`,[a])).length>0?(await o.execute(`UPDATE ${t} SET data = ? WHERE key = ?`,[r,a]),{key:a,op:"update"}):(await o.execute(`INSERT INTO ${t} (key, data) VALUES (?, ?)`,[a,r]),{key:a,op:"insert"})}await o.execute(`INSERT INTO ${t} (data) VALUES (?)`,[r]);const c=(await o.select("SELECT last_insert_rowid() AS id"))[0]?.id;if(typeof c!="number")throw new Error("Failed to retrieve last_insert_rowid()");return{key:c,op:"insert"}},async get(e,n=o=>o){const o=u?"key = ?":"rowid = ?",r=await s.select(`SELECT rowid, data FROM ${t} WHERE ${o}`,[e]);if(r.length===0)return;const{data:E,rowid:c}=r[0],a=JSON.parse(E),i=u?A(a)??c:c;return n(a,{rowId:c,key:i})},async delete(e){const n=u?"key = ?":"rowid = ?";if(await s.execute(`DELETE FROM ${t} WHERE ${n}`,[e]),((await s.select("SELECT changes() AS c"))[0]?.c??0)>0)return{key:e,op:"delete"}},async*search(e={}){const{sortBy:n,order:o="asc",limit:r,offset:E=0,where:c,select:a=w=>w,pageSize:i=C}=e,S=g(c,t),h=`SELECT rowid, data FROM ${t} ${S}`;let p=0,I=E;for(;;){let w=h;n?w+=` ORDER BY json_extract(data, '${m(String(n))}') COLLATE NOCASE ${o.toUpperCase()}`:w+=u?` ORDER BY key COLLATE NOCASE ${o.toUpperCase()}`:` ORDER BY rowid ${o.toUpperCase()}`;const N=r?Math.min(i,r-p):i;w+=` LIMIT ${N} OFFSET ${I}`;const R=await s.select(w);if(R.length===0)break;for(const{rowid:O,data:F}of R){if(r&&p>=r)return;const D=JSON.parse(F),b=u?A(D)??O:O;yield a(D,{rowId:O,key:b}),p++}if(R.length<N||r&&p>=r)break;I+=R.length}},async count(e={}){const n=g(e.where,t),o=`SELECT COUNT(*) as count FROM ${t} ${n}`;return(await s.select(o))[0]?.count??0},async deleteBy(e){const n=g(e,t),o=u?"key":"rowid",r=[];return await s.transaction(async E=>{const c=await E.select(`SELECT ${o} AS k FROM ${t} ${n}`);if(c.length===0)return;const a=c.map(i=>i.k);for(let i=0;i<a.length;i+=x){const S=a.slice(i,i+x),h=S.map(()=>"?").join(",");await E.execute(`DELETE FROM ${t} WHERE ${o} IN (${h})`,S)}for(const i of a)r.push({key:i,op:"delete"})}),r},async clear(){await s.execute(`DELETE FROM ${t}`)},async batchSet(e){const n=[];return await s.transaction(async o=>{for(const r of e){const E=await k.set(r,o);n.push(E)}}),n}};return k}export{C as DEFAULT_PAGE_SIZE,G as createTable,M as getByPath,m as toJsonPath};
@@ -1 +1 @@
1
- import{useCallback as f,useLayoutEffect as A,useReducer as R,useRef as x}from"react";import{DEFAULT_PAGE_SIZE as v}from"./table";const I=1e4;function _(u,D={},g=[]){const{select:p,pageSize:P=v}=D,e=x(),[,k]=R(n=>n+1,0),t=x(new Map),S=x(),w=f(()=>{const{select:n,...o}=D;S.current=u.search({select:(c,s)=>({doc:c,meta:s}),...o})},[u,...g]),i=f(()=>{e.current=[],t.current.clear(),w()},[w]),m=f(async n=>{e.current===void 0&&(e.current=[]),n===!0&&i();const{current:o}=S;if(!o)return!0;let c=!1;for(let s=0;s<P;s++){const r=await o.next();if(r.done){S.current=void 0,c=!0;break}if(t.current.has(r.value.meta.key)){s+=-1;continue}e.current.push(p?p(r.value.doc):r.value.doc),t.current.set(r.value.meta.key,e.current.length-1)}return c},[]),y=f(async()=>{const n=await m(!1);return k(),n},[m]);A(()=>{const n=u.subscribe(async o=>{const{mutations:c,removedAll:s}=o;if(s&&i(),!c)return;const r=e.current?.length??0;let b=r,h=!1;for(const l of c){const{key:a,op:q}=l;switch(q){case"insert":{b+=1;break}case"delete":{if(e.current&&t.current.has(a)){const d=t.current.get(a);if(d===void 0)break;e.current.splice(d,1),t.current.delete(a),e.current=[...e.current],h=!0}break}case"update":{if(e.current&&t.current.has(a)){const d=t.current.get(a);if(d===void 0)break;e.current[d]=await u.get(a,p),e.current=[...e.current],h=!0}break}}}const L=r!==b;if(L||h){if(L){await m(!0);let l=0;for(;(e.current?.length??0)<b&&l<I;)await m(!1),l++;l===I&&console.warn("Reached maximum iterations in fillNextPage loop. Possible duplicate or data issue.")}k()}});return()=>{n()}},[u]),A(()=>{i(),y()},g);const T=f(async()=>{i(),await y()},[y,i]);return[e.current,{nextPage:y,reset:T,keysIndex:t.current}]}export{_ as useSqliteValue};
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};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muya",
3
- "version": "2.4.6",
3
+ "version": "2.4.8",
4
4
  "author": "samuel.gjabel@gmail.com",
5
5
  "repository": "https://github.com/samuelgjabel/muya",
6
6
  "main": "cjs/index.js",
@@ -0,0 +1,431 @@
1
+ import { act, renderHook } from '@testing-library/react-hooks'
2
+ import { createSqliteState } from '../create-sqlite'
3
+ import { useSqliteValue } from '../use-sqlite'
4
+ import { waitFor } from '@testing-library/react'
5
+ import { bunMemoryBackend } from '../table/bun-backend'
6
+ import { StrictMode, Suspense } from 'react'
7
+
8
+ const backend = bunMemoryBackend()
9
+
10
+ interface Person {
11
+ id: string
12
+ name: string
13
+ age: number
14
+ }
15
+
16
+ /**
17
+ * Wrapper component to provide necessary React context for testing.
18
+ * @param props - The props object containing children.
19
+ * @param props.children - The children to render inside the wrapper.
20
+ * @returns The wrapped children with React context.
21
+ */
22
+ function Wrapper({ children }: Readonly<{ children: React.ReactNode }>) {
23
+ return (
24
+ <StrictMode>
25
+ <Suspense fallback={<div>Loading...</div>}>{children}</Suspense>
26
+ </StrictMode>
27
+ )
28
+ }
29
+
30
+ describe('use-sqlite edge cases', () => {
31
+ it('should remove an item and verify it is removed', async () => {
32
+ const sql = createSqliteState<Person>({ backend, tableName: 'RemoveTest', key: 'id' })
33
+ await sql.batchSet([
34
+ { id: '1', name: 'Alice', age: 30 },
35
+ { id: '2', name: 'Bob', age: 25 },
36
+ ])
37
+
38
+ const { result } = renderHook(() => useSqliteValue(sql, {}, []), { wrapper: Wrapper })
39
+
40
+ await waitFor(() => {
41
+ expect(result.current[0]).toEqual([
42
+ { id: '1', name: 'Alice', age: 30 },
43
+ { id: '2', name: 'Bob', age: 25 },
44
+ ])
45
+ expect(result.current[1].keysIndex).toEqual(
46
+ new Map([
47
+ ['1', 0],
48
+ ['2', 1],
49
+ ]),
50
+ )
51
+ })
52
+
53
+ act(() => {
54
+ sql.delete('1')
55
+ })
56
+
57
+ await waitFor(() => {
58
+ expect(result.current[0]).toEqual([{ id: '2', name: 'Bob', age: 25 }])
59
+ expect(result.current[1].keysIndex).toEqual(new Map([['2', 0]]))
60
+ })
61
+
62
+ act(() => {
63
+ sql.delete('2')
64
+ })
65
+
66
+ await waitFor(() => {
67
+ expect(result.current[0]).toEqual([])
68
+ })
69
+ })
70
+ it('should handle deleting a non-existent item gracefully', async () => {
71
+ const sql = createSqliteState<Person>({ backend, tableName: 'EdgeCaseTest', key: 'id' })
72
+ await sql.batchSet([
73
+ { id: '1', name: 'Alice', age: 30 },
74
+ { id: '2', name: 'Bob', age: 25 },
75
+ ])
76
+
77
+ const { result } = renderHook(() => useSqliteValue(sql, {}, []), { wrapper: Wrapper })
78
+
79
+ await waitFor(() => {
80
+ expect(result.current[0]).toEqual([
81
+ { id: '1', name: 'Alice', age: 30 },
82
+ { id: '2', name: 'Bob', age: 25 },
83
+ ])
84
+ })
85
+
86
+ act(() => {
87
+ sql.delete('3') // Attempt to delete a non-existent item
88
+ })
89
+
90
+ await waitFor(() => {
91
+ expect(result.current[0]).toEqual([
92
+ { id: '1', name: 'Alice', age: 30 },
93
+ { id: '2', name: 'Bob', age: 25 },
94
+ ]) // State should remain unchanged
95
+ })
96
+ })
97
+
98
+ it('should handle deleting all items', async () => {
99
+ const sql = createSqliteState<Person>({ backend, tableName: 'DeleteAllTest', key: 'id' })
100
+ await sql.batchSet([
101
+ { id: '1', name: 'Alice', age: 30 },
102
+ { id: '2', name: 'Bob', age: 25 },
103
+ ])
104
+
105
+ const { result } = renderHook(() => useSqliteValue(sql, {}, []), { wrapper: Wrapper })
106
+
107
+ await waitFor(() => {
108
+ expect(result.current[0]).toEqual([
109
+ { id: '1', name: 'Alice', age: 30 },
110
+ { id: '2', name: 'Bob', age: 25 },
111
+ ])
112
+ })
113
+
114
+ act(() => {
115
+ sql.delete('1')
116
+ sql.delete('2')
117
+ })
118
+
119
+ await waitFor(() => {
120
+ expect(result.current[0]).toEqual([]) // All items should be removed
121
+ })
122
+ })
123
+
124
+ it('should handle concurrent operations', async () => {
125
+ const sql = createSqliteState<Person>({ backend, tableName: 'ConcurrentOpsTest', key: 'id' })
126
+ await sql.batchSet([
127
+ { id: '1', name: 'Alice', age: 30 },
128
+ { id: '2', name: 'Bob', age: 25 },
129
+ ])
130
+
131
+ const { result } = renderHook(() => useSqliteValue(sql, {}, []), { wrapper: Wrapper })
132
+
133
+ await waitFor(() => {
134
+ expect(result.current[0]).toEqual([
135
+ { id: '1', name: 'Alice', age: 30 },
136
+ { id: '2', name: 'Bob', age: 25 },
137
+ ])
138
+ })
139
+
140
+ act(() => {
141
+ sql.delete('1')
142
+ sql.set({ id: '3', name: 'Carol', age: 40 })
143
+ })
144
+
145
+ await waitFor(() => {
146
+ expect(result.current[0]).toEqual([
147
+ { id: '2', name: 'Bob', age: 25 },
148
+ { id: '3', name: 'Carol', age: 40 },
149
+ ]) // State should reflect both operations
150
+ })
151
+ })
152
+
153
+ it('should handle repeated updates, removals, and insertions in a loop', async () => {
154
+ const sql = createSqliteState<Person>({ backend, tableName: 'LoopTest', key: 'id' })
155
+ sql.clear()
156
+ // Initial batch set
157
+ await sql.batchSet([
158
+ { id: '1', name: 'Alice', age: 30 },
159
+ { id: '2', name: 'Bob', age: 25 },
160
+ ])
161
+
162
+ const { result } = renderHook(() => useSqliteValue(sql, {}, []), { wrapper: Wrapper })
163
+
164
+ await waitFor(() => {
165
+ expect(result.current[0]).toEqual([
166
+ { id: '1', name: 'Alice', age: 30 },
167
+ { id: '2', name: 'Bob', age: 25 },
168
+ ])
169
+ })
170
+
171
+ // Perform updates, removals, and insertions in a loop
172
+ act(() => {
173
+ for (let index = 0; index < 10; index++) {
174
+ sql.set({ id: `new-${index}`, name: `Person ${index}`, age: 20 + index }) // Insert new item
175
+ sql.delete('1') // Remove an existing item
176
+ sql.set({ id: '2', name: `Updated Bob ${index}`, age: 25 + index }) // Update an existing item
177
+ }
178
+ // fetch next
179
+ result.current[1].nextPage()
180
+ })
181
+
182
+ await waitFor(() => {
183
+ expect(result.current[0]).toEqual([
184
+ { id: '2', name: 'Updated Bob 9', age: 34 },
185
+ { id: 'new-0', name: 'Person 0', age: 20 },
186
+ { id: 'new-1', name: 'Person 1', age: 21 },
187
+ { id: 'new-2', name: 'Person 2', age: 22 },
188
+ { id: 'new-3', name: 'Person 3', age: 23 },
189
+ { id: 'new-4', name: 'Person 4', age: 24 },
190
+ { id: 'new-5', name: 'Person 5', age: 25 },
191
+ { id: 'new-6', name: 'Person 6', age: 26 },
192
+ { id: 'new-7', name: 'Person 7', age: 27 },
193
+ { id: 'new-8', name: 'Person 8', age: 28 },
194
+ { id: 'new-9', name: 'Person 9', age: 29 },
195
+ ])
196
+ })
197
+ })
198
+ it('should handle concurrent insertions and deletions', async () => {
199
+ const sql = createSqliteState<Person>({ backend, tableName: 'ConcurrentInsertDeleteTest', key: 'id' })
200
+ await sql.batchSet([
201
+ { id: '1', name: 'Alice', age: 30 },
202
+ { id: '2', name: 'Bob', age: 25 },
203
+ ])
204
+
205
+ const { result } = renderHook(() => useSqliteValue(sql, {}, []), { wrapper: Wrapper })
206
+
207
+ await waitFor(() => {
208
+ expect(result.current[0]).toEqual([
209
+ { id: '1', name: 'Alice', age: 30 },
210
+ { id: '2', name: 'Bob', age: 25 },
211
+ ])
212
+ })
213
+
214
+ act(() => {
215
+ sql.set({ id: '3', name: 'Carol', age: 40 })
216
+ sql.delete('1')
217
+ })
218
+
219
+ await waitFor(() => {
220
+ expect(result.current[0]).toEqual([
221
+ { id: '2', name: 'Bob', age: 25 },
222
+ { id: '3', name: 'Carol', age: 40 },
223
+ ])
224
+ })
225
+ })
226
+
227
+ it('should handle pagination with empty pages', async () => {
228
+ const sql = createSqliteState<Person>({ backend, tableName: 'EmptyPaginationTest', key: 'id' })
229
+ sql.clear()
230
+
231
+ const { result } = renderHook(() => useSqliteValue(sql, {}, []), { wrapper: Wrapper })
232
+
233
+ await waitFor(() => {
234
+ expect(result.current[0]).toEqual([])
235
+ })
236
+
237
+ act(() => {
238
+ result.current[1].nextPage()
239
+ })
240
+
241
+ await waitFor(() => {
242
+ expect(result.current[0]).toEqual([]) // Still empty
243
+ })
244
+ })
245
+
246
+ it('should handle duplicate key insertions gracefully', async () => {
247
+ const sql = createSqliteState<Person>({ backend, tableName: 'DuplicateKeyTest', key: 'id' })
248
+ await sql.batchSet([{ id: '1', name: 'Alice', age: 30 }])
249
+
250
+ const { result } = renderHook(() => useSqliteValue(sql, {}, []), { wrapper: Wrapper })
251
+
252
+ await waitFor(() => {
253
+ expect(result.current[0]).toEqual([{ id: '1', name: 'Alice', age: 30 }])
254
+ })
255
+
256
+ act(() => {
257
+ sql.set({ id: '1', name: 'Updated Alice', age: 35 })
258
+ })
259
+
260
+ await waitFor(() => {
261
+ expect(result.current[0]).toEqual([{ id: '1', name: 'Updated Alice', age: 35 }])
262
+ })
263
+ })
264
+
265
+ it('should handle reset during pagination', async () => {
266
+ const sql = createSqliteState<Person>({ backend, tableName: 'ResetDuringPaginationTest', key: 'id' })
267
+ await sql.batchSet([
268
+ { id: '1', name: 'Alice', age: 30 },
269
+ { id: '2', name: 'Bob', age: 25 },
270
+ ])
271
+
272
+ const { result } = renderHook(() => useSqliteValue(sql, {}, []), { wrapper: Wrapper })
273
+
274
+ await waitFor(() => {
275
+ expect(result.current[0]).toEqual([
276
+ { id: '1', name: 'Alice', age: 30 },
277
+ { id: '2', name: 'Bob', age: 25 },
278
+ ])
279
+ })
280
+
281
+ act(() => {
282
+ result.current[1].reset()
283
+ result.current[1].nextPage()
284
+ })
285
+
286
+ await waitFor(() => {
287
+ expect(result.current[0]).toEqual([
288
+ { id: '1', name: 'Alice', age: 30 },
289
+ { id: '2', name: 'Bob', age: 25 },
290
+ ])
291
+ })
292
+ })
293
+
294
+ it('should handle invalid key deletion gracefully', async () => {
295
+ const sql = createSqliteState<Person>({ backend, tableName: 'InvalidKeyDeletionTest', key: 'id' })
296
+ await sql.batchSet([
297
+ { id: '1', name: 'Alice', age: 30 },
298
+ { id: '2', name: 'Bob', age: 25 },
299
+ ])
300
+
301
+ const { result } = renderHook(() => useSqliteValue(sql, {}, []), { wrapper: Wrapper })
302
+
303
+ await waitFor(() => {
304
+ expect(result.current[0]).toEqual([
305
+ { id: '1', name: 'Alice', age: 30 },
306
+ { id: '2', name: 'Bob', age: 25 },
307
+ ])
308
+ })
309
+
310
+ act(() => {
311
+ sql.delete('non-existent-key')
312
+ })
313
+
314
+ await waitFor(() => {
315
+ expect(result.current[0]).toEqual([
316
+ { id: '1', name: 'Alice', age: 30 },
317
+ { id: '2', name: 'Bob', age: 25 },
318
+ ])
319
+ })
320
+ })
321
+ it('should update a visible document', async () => {
322
+ const sql = createSqliteState<Person>({ backend, tableName: 'UpdateVisibleTest', key: 'id' })
323
+ await sql.batchSet([
324
+ { id: '1', name: 'Alice', age: 30 },
325
+ { id: '2', name: 'Bob', age: 25 },
326
+ ])
327
+
328
+ const { result } = renderHook(() => useSqliteValue(sql, {}, []), { wrapper: Wrapper })
329
+
330
+ await waitFor(() => {
331
+ expect(result.current[0]).toEqual([
332
+ { id: '1', name: 'Alice', age: 30 },
333
+ { id: '2', name: 'Bob', age: 25 },
334
+ ])
335
+ })
336
+
337
+ act(() => {
338
+ sql.set({ id: '1', name: 'Updated Alice', age: 35 })
339
+ })
340
+
341
+ await waitFor(() => {
342
+ expect(result.current[0]).toEqual([
343
+ { id: '1', name: 'Updated Alice', age: 35 },
344
+ { id: '2', name: 'Bob', age: 25 },
345
+ ])
346
+ })
347
+ })
348
+
349
+ it('should update a non-visible document', async () => {
350
+ const sql = createSqliteState<Person>({ backend, tableName: 'UpdateNonVisibleTest', key: 'id' })
351
+ await sql.batchSet([
352
+ { id: '1', name: 'Alice', age: 30 },
353
+ { id: '2', name: 'Bob', age: 25 },
354
+ { id: '3', name: 'Carol', age: 40 },
355
+ ])
356
+
357
+ const { result } = renderHook(() => useSqliteValue(sql, { pageSize: 2 }, []), { wrapper: Wrapper })
358
+
359
+ await waitFor(() => {
360
+ expect(result.current[0]).toEqual([
361
+ { id: '1', name: 'Alice', age: 30 },
362
+ { id: '2', name: 'Bob', age: 25 },
363
+ ])
364
+ })
365
+
366
+ act(() => {
367
+ sql.set({ id: '3', name: 'Updated Carol', age: 45 })
368
+ })
369
+
370
+ await waitFor(() => {
371
+ expect(result.current[0]).toEqual([
372
+ { id: '1', name: 'Alice', age: 30 },
373
+ { id: '2', name: 'Bob', age: 25 },
374
+ ]) // No change in visible items
375
+ })
376
+
377
+ act(() => {
378
+ result.current[1].nextPage()
379
+ })
380
+
381
+ await waitFor(() => {
382
+ expect(result.current[0]).toEqual([
383
+ { id: '1', name: 'Alice', age: 30 },
384
+ { id: '2', name: 'Bob', age: 25 },
385
+ { id: '3', name: 'Updated Carol', age: 45 },
386
+ ])
387
+ })
388
+ })
389
+
390
+ it('should handle updates during pagination', async () => {
391
+ const sql = createSqliteState<Person>({ backend, tableName: 'UpdateDuringPaginationTest', key: 'id' })
392
+ await sql.batchSet([
393
+ { id: '1', name: 'Alice', age: 30 },
394
+ { id: '2', name: 'Bob', age: 25 },
395
+ { id: '3', name: 'Carol', age: 40 },
396
+ ])
397
+
398
+ const { result } = renderHook(() => useSqliteValue(sql, { pageSize: 2 }, []), { wrapper: Wrapper })
399
+
400
+ await waitFor(() => {
401
+ expect(result.current[0]).toEqual([
402
+ { id: '1', name: 'Alice', age: 30 },
403
+ { id: '2', name: 'Bob', age: 25 },
404
+ ])
405
+ })
406
+
407
+ act(() => {
408
+ result.current[1].nextPage()
409
+ })
410
+
411
+ await waitFor(() => {
412
+ expect(result.current[0]).toEqual([
413
+ { id: '1', name: 'Alice', age: 30 },
414
+ { id: '2', name: 'Bob', age: 25 },
415
+ { id: '3', name: 'Carol', age: 40 },
416
+ ])
417
+ })
418
+
419
+ act(() => {
420
+ sql.set({ id: '2', name: 'Updated Bob', age: 35 })
421
+ })
422
+
423
+ await waitFor(() => {
424
+ expect(result.current[0]).toEqual([
425
+ { id: '1', name: 'Alice', age: 30 },
426
+ { id: '2', name: 'Updated Bob', age: 35 },
427
+ { id: '3', name: 'Carol', age: 40 },
428
+ ])
429
+ })
430
+ })
431
+ })
@@ -24,7 +24,7 @@ export interface SyncTable<Document extends DocType> {
24
24
  readonly search: <Selected = Document>(options?: SearchOptions<Document, Selected>) => AsyncIterableIterator<Selected>
25
25
  readonly count: (options?: { where?: Where<Document> }) => Promise<number>
26
26
  readonly deleteBy: (where: Where<Document>) => Promise<MutationResult[]>
27
- readonly clear: () => void
27
+ readonly clear: () => Promise<void>
28
28
  }
29
29
 
30
30
  /**
@@ -87,9 +87,10 @@ export function createSqliteState<Document extends DocType>(options: CreateSqlit
87
87
  listeners.add(listener)
88
88
  return () => listeners.delete(listener)
89
89
  },
90
- clear() {
91
- cachedTable?.clear()
90
+ async clear() {
91
+ const table = await getTable()
92
92
  handleChanges({ removedAll: true })
93
+ return table.clear()
93
94
  },
94
95
  async set(document) {
95
96
  const table = await getTable()
@@ -164,16 +164,6 @@ export async function createTable<Document extends DocType>(options: DbOptions<D
164
164
  return getByPath(document, String(key)) as Key | undefined
165
165
  }
166
166
 
167
- /**
168
- * Get the number of rows changed by the last operation on the given connection
169
- * @param conn The database connection to check for changes
170
- * @returns A promise that resolves to the number of changed rows
171
- */
172
- async function getChanges(conn: typeof backend): Promise<number> {
173
- const r = await conn.select<Array<{ c: number }>>(`SELECT changes() AS c`)
174
- return r[0]?.c ?? 0
175
- }
176
-
177
167
  const table: Table<Document> = {
178
168
  backend,
179
169
 
@@ -187,16 +177,14 @@ export async function createTable<Document extends DocType>(options: DbOptions<D
187
177
  throw new Error(`Document is missing the configured key "${String(key)}".`)
188
178
  }
189
179
 
190
- await db.execute(`UPDATE ${tableName} SET data = ? WHERE key = ?`, [json, id])
191
- const updated = await getChanges(db)
192
- if (updated === 1) return { key: id, op: 'update' }
180
+ const existing = await db.select<Array<{ key: string }>>(`SELECT key FROM ${tableName} WHERE key = ?`, [id])
193
181
 
194
- try {
195
- await db.execute(`INSERT INTO ${tableName} (key, data) VALUES (?, ?)`, [id, json])
196
- return { key: id, op: 'insert' }
197
- } catch {
182
+ if (existing.length > 0) {
198
183
  await db.execute(`UPDATE ${tableName} SET data = ? WHERE key = ?`, [json, id])
199
184
  return { key: id, op: 'update' }
185
+ } else {
186
+ await db.execute(`INSERT INTO ${tableName} (key, data) VALUES (?, ?)`, [id, json])
187
+ return { key: id, op: 'insert' }
200
188
  }
201
189
  }
202
190
 
@@ -42,10 +42,10 @@ export function useSqliteValue<Document extends DocType, Selected = Document>(
42
42
  ): [(undefined extends Selected ? Document[] : Selected[]) | undefined, SqLiteActions] {
43
43
  const { select, pageSize = DEFAULT_PAGE_SIZE } = options
44
44
 
45
- const itemsRef = useRef<undefined | (Document | Selected)[]>()
45
+ const itemsRef = useRef<null | (Document | Selected)[]>(null)
46
46
  const [, rerender] = useReducer((c: number) => c + 1, 0)
47
47
  const keysIndex = useRef(new Map<Key, number>())
48
- const iteratorRef = useRef<AsyncIterableIterator<{ doc: Document; meta: { key: Key } }>>()
48
+ const iteratorRef = useRef<AsyncIterableIterator<{ doc: Document; meta: { key: Key } }> | null>(null)
49
49
 
50
50
  const updateIterator = useCallback(() => {
51
51
  // eslint-disable-next-line sonarjs/no-unused-vars
@@ -61,7 +61,7 @@ export function useSqliteValue<Document extends DocType, Selected = Document>(
61
61
  }, [updateIterator])
62
62
 
63
63
  const fillNextPage = useCallback(async (shouldReset: boolean) => {
64
- if (itemsRef.current === undefined) {
64
+ if (itemsRef.current === null) {
65
65
  itemsRef.current = []
66
66
  }
67
67
  if (shouldReset === true) {
@@ -73,10 +73,11 @@ export function useSqliteValue<Document extends DocType, Selected = Document>(
73
73
  return true
74
74
  }
75
75
  let isDone = false
76
+
76
77
  for (let index = 0; index < pageSize; index++) {
77
78
  const result = await iterator.next()
78
79
  if (result.done) {
79
- iteratorRef.current = undefined
80
+ iteratorRef.current = null
80
81
  isDone = true
81
82
  break
82
83
  }
@@ -88,6 +89,7 @@ export function useSqliteValue<Document extends DocType, Selected = Document>(
88
89
  itemsRef.current.push(select ? select(result.value.doc) : (result.value.doc as unknown as Selected))
89
90
  keysIndex.current.set(result.value.meta.key, itemsRef.current.length - 1)
90
91
  }
92
+ itemsRef.current = [...itemsRef.current]
91
93
  return isDone
92
94
  // eslint-disable-next-line react-hooks/exhaustive-deps
93
95
  }, [])
@@ -111,6 +113,7 @@ export function useSqliteValue<Document extends DocType, Selected = Document>(
111
113
  const oldLength = itemsRef.current?.length ?? 0
112
114
  let newLength = oldLength
113
115
  let hasUpdate = false
116
+ const removeIndexes = new Set<number>()
114
117
  for (const mutation of mutations) {
115
118
  const { key, op } = mutation
116
119
  switch (op) {
@@ -119,37 +122,57 @@ export function useSqliteValue<Document extends DocType, Selected = Document>(
119
122
  break
120
123
  }
121
124
  case 'delete': {
122
- if (itemsRef.current && keysIndex.current.has(key)) {
125
+ if (itemsRef.current && itemsRef.current.length > 0 && keysIndex.current.has(key)) {
123
126
  const index = keysIndex.current.get(key)
124
127
  if (index === undefined) break
125
- itemsRef.current.splice(index, 1)
126
- keysIndex.current.delete(key)
127
- itemsRef.current = [...itemsRef.current]
128
+ removeIndexes.add(index)
128
129
  hasUpdate = true
129
130
  }
130
131
  break
131
132
  }
132
133
  case 'update': {
133
- if (itemsRef.current && keysIndex.current.has(key)) {
134
+ if (keysIndex.current.has(key)) {
134
135
  const index = keysIndex.current.get(key)
135
- if (index === undefined) break
136
- itemsRef.current[index] = (await state.get(key, select)) as Selected
137
- itemsRef.current = [...itemsRef.current]
138
- hasUpdate = true
136
+ if (index !== undefined && itemsRef.current) {
137
+ itemsRef.current[index] = (await state.get(key, select)) as Selected
138
+ itemsRef.current = [...itemsRef.current]
139
+ hasUpdate = true
140
+ }
141
+ } else {
142
+ // Handle updates to non-visible items
143
+ const updatedItem = await state.get(key, select)
144
+ if (updatedItem) {
145
+ itemsRef.current = [...(itemsRef.current ?? []), updatedItem]
146
+ keysIndex.current.set(key, itemsRef.current.length - 1)
147
+ hasUpdate = true
148
+ }
139
149
  }
140
150
  break
141
151
  }
142
152
  }
143
153
  }
144
154
 
155
+ if (removeIndexes.size > 0 && itemsRef.current && itemsRef.current.length > 0) {
156
+ const newIndex = new Map<Key, number>()
157
+ itemsRef.current = itemsRef.current?.filter((_, index) => {
158
+ return !removeIndexes.has(index)
159
+ })
160
+ let newIdx = 0
161
+ for (const [key, index] of keysIndex.current) {
162
+ if (removeIndexes.has(index)) {
163
+ continue
164
+ }
165
+ newIndex.set(key, newIdx)
166
+ newIdx++
167
+ }
168
+ keysIndex.current = newIndex
169
+ }
170
+
145
171
  const isLengthChanged = oldLength !== newLength
146
172
  const isChanged = isLengthChanged || hasUpdate
147
173
  if (!isChanged) return
148
174
  if (isLengthChanged) {
149
175
  await fillNextPage(true)
150
-
151
- // here we ensure that if the length changed, we fill the next page
152
-
153
176
  let iterations = 0
154
177
  while ((itemsRef.current?.length ?? 0) < newLength && iterations < MAX_ITERATIONS) {
155
178
  await fillNextPage(false)
@@ -19,7 +19,7 @@ export interface SyncTable<Document extends DocType> {
19
19
  where?: Where<Document>;
20
20
  }) => Promise<number>;
21
21
  readonly deleteBy: (where: Where<Document>) => Promise<MutationResult[]>;
22
- readonly clear: () => void;
22
+ readonly clear: () => Promise<void>;
23
23
  }
24
24
  /**
25
25
  * Create a SyncTable that wraps a Table and provides reactive capabilities