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.
- package/esm/sqlite/create-sqlite.js +1 -1
- package/esm/sqlite/table/table.js +10 -10
- package/esm/sqlite/use-sqlite.js +1 -1
- package/package.json +1 -1
- package/src/sqlite/__tests__/use-sqlite.more.test.tsx +431 -0
- package/src/sqlite/create-sqlite.ts +4 -3
- package/src/sqlite/table/table.ts +5 -17
- package/src/sqlite/use-sqlite.ts +39 -16
- package/types/sqlite/create-sqlite.d.ts +1 -1
|
@@ -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(){
|
|
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
|
|
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
|
|
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),
|
|
11
|
-
ON ${t} (json_extract(data, '${
|
|
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
|
|
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(
|
|
21
|
+
${T.map(r=>`json_extract(new.data, '${m(r)}')`).join(", ")}
|
|
22
22
|
);
|
|
23
23
|
END;
|
|
24
|
-
`),await
|
|
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
|
|
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(
|
|
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
|
|
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};
|
package/esm/sqlite/use-sqlite.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{useCallback as f,useLayoutEffect as
|
|
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
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
package/src/sqlite/use-sqlite.ts
CHANGED
|
@@ -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<
|
|
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 ===
|
|
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 =
|
|
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
|
-
|
|
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 (
|
|
134
|
+
if (keysIndex.current.has(key)) {
|
|
134
135
|
const index = keysIndex.current.get(key)
|
|
135
|
-
if (index
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|