muya 2.3.3 → 2.3.5

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{bunMemoryBackend as r}from"../table/bun-backend";import{createTable as l}from"../table/table";describe("table",()=>{let c=r(),e,s;beforeEach(async()=>{c=r(),e=await l({backend:c,tableName:"TestTable",key:"name"}),s=await l({backend:c,tableName:"TestTableNested",key:"info.name",indexes:["info.age","info.city"]})}),it("should set and get items",async()=>{const a=await e.set({name:"Alice",age:30,city:"Paris"});expect(a.key).toBe("Alice"),expect(a.op).toBe("insert");const o=await e.get("Alice");expect(o).toEqual({name:"Alice",age:30,city:"Paris"});const t=await e.set({name:"Alice",age:31,city:"Paris"});expect(t.key).toBe("Alice"),expect(t.op).toBe("update");const i=await e.get("Alice");expect(i).toEqual({name:"Alice",age:31,city:"Paris"})}),it("should set and get nested key",async()=>{const a=await s.set({info:{name:"Bob",age:25,city:"London"}});expect(a.key).toBe("Bob"),expect(a.op).toBe("insert");const o=await s.get("Bob");expect(o).toEqual({info:{name:"Bob",age:25,city:"London"}});const t=await s.set({info:{name:"Bob",age:26,city:"London"}});expect(t.key).toBe("Bob"),expect(t.op).toBe("update");const i=await s.get("Bob");expect(i).toEqual({info:{name:"Bob",age:26,city:"London"}});const n=[];for await(const d of s.search({where:{info:{city:{like:"London"}}}}))n.push(d);expect(n.length).toBe(1)}),it("should count items and count with where",async()=>{await e.set({name:"Alice",age:30,city:"Paris"}),await e.set({name:"Bob",age:25,city:"London"}),expect(await e.count()).toBe(2),expect(await e.count({where:{city:"Paris"}})).toBe(1)}),it("should search with ordering, limit and offset",async()=>{const a=[{name:"Alice",age:30,city:"Paris"},{name:"Bob",age:25,city:"London"},{name:"Carol",age:35,city:"Berlin"}];for(const n of a)await e.set(n);const o=[];for await(const n of e.search({sortBy:"age",order:"asc"}))o.push(n);expect(o.map(n=>n.name)).toEqual(["Bob","Alice","Carol"]);const t=[];for await(const n of e.search({sortBy:"age",order:"asc",limit:2}))t.push(n);expect(t.map(n=>n.name)).toEqual(["Bob","Alice"]);const i=[];for await(const n of e.search({sortBy:"age",order:"asc",offset:1,limit:2}))i.push(n);expect(i.map(n=>n.name)).toEqual(["Alice","Carol"])}),it("should deleteBy where clause",async()=>{await e.set({name:"Dave",age:40,city:"NY"}),await e.set({name:"Eve",age:45,city:"NY"}),await e.set({name:"Frank",age:50,city:"LA"}),expect(await e.count()).toBe(3),await e.deleteBy({city:"NY"}),expect(await e.count()).toBe(1),expect(await e.get("Frank")).toEqual({name:"Frank",age:50,city:"LA"}),expect(await e.get("Dave")).toBeUndefined()}),it("should use selector in get and search",async()=>{await e.set({name:"Gary",age:60,city:"SF"});const a=await e.get("Gary",({age:t})=>t);expect(a).toBe(60);const o=[];for await(const t of e.search({select:({city:i})=>i}))o.push(t);expect(o).toEqual(["SF"])}),it("should delete items by key",async()=>{await e.set({name:"Helen",age:28,city:"Rome"}),expect(await e.get("Helen")).toBeDefined(),await e.delete("Helen"),expect(await e.get("Helen")).toBeUndefined()}),it("should test search with 1000 items",async()=>{const a=[];for(let t=0;t<1e3;t++)a.push({name:`Person${t}`,age:Math.floor(Math.random()*100),city:"City"+t%10});for(const t of a)await e.set(t);const o=[];for await(const t of e.search({sortBy:"age",order:"asc",limit:100}))o.push(t);expect(o.length).toBe(100)}),it("should handle operations on an empty table",async()=>{expect(await e.count()).toBe(0),expect(await e.get("NonExistent")).toBeUndefined();const a=[];for await(const o of e.search({sortBy:"age",order:"asc"}))a.push(o);expect(a.length).toBe(0)}),it("should handle duplicate keys gracefully",async()=>{await e.set({name:"Alice",age:30,city:"Paris"}),await e.set({name:"Alice",age:35,city:"Berlin"});const a=await e.get("Alice");expect(a).toEqual({name:"Alice",age:35,city:"Berlin"})}),it("should handle edge cases in selectors",async()=>{await e.set({name:"Charlie",age:40,city:"NY"});const a=await e.get("Charlie",()=>null);expect(a).toBeNull();const o=await e.get("Charlie",()=>{});expect(o).toBeUndefined()})});
1
+ import{bunMemoryBackend as l}from"../table/bun-backend";import{createTable as c}from"../table/table";describe("table",()=>{let i=l(),t,r;beforeEach(async()=>{i=l(),t=await c({backend:i,tableName:"TestTable",key:"name"}),r=await c({backend:i,tableName:"TestTableNested",key:"info.name",indexes:["info.age","info.city"]})}),it("should set and get items",async()=>{const e=await t.set({name:"Alice",age:30,city:"Paris"});expect(e.key).toBe("Alice"),expect(e.op).toBe("insert");const o=await t.get("Alice");expect(o).toEqual({name:"Alice",age:30,city:"Paris"});const a=await t.set({name:"Alice",age:31,city:"Paris"});expect(a.key).toBe("Alice"),expect(a.op).toBe("update");const n=await t.get("Alice");expect(n).toEqual({name:"Alice",age:31,city:"Paris"})}),it("should set and get nested key",async()=>{const e=await r.set({info:{name:"Bob",age:25,city:"London"}});expect(e.key).toBe("Bob"),expect(e.op).toBe("insert");const o=await r.get("Bob");expect(o).toEqual({info:{name:"Bob",age:25,city:"London"}});const a=await r.set({info:{name:"Bob",age:26,city:"London"}});expect(a.key).toBe("Bob"),expect(a.op).toBe("update");const n=await r.get("Bob");expect(n).toEqual({info:{name:"Bob",age:26,city:"London"}});const s=[];for await(const d of r.search({where:{info:{city:{like:"London"}}}}))s.push(d);expect(s.length).toBe(1)}),it("should count items and count with where",async()=>{await t.set({name:"Alice",age:30,city:"Paris"}),await t.set({name:"Bob",age:25,city:"London"}),expect(await t.count()).toBe(2),expect(await t.count({where:{city:"Paris"}})).toBe(1)}),it("should search with ordering, limit and offset",async()=>{const e=[{name:"Alice",age:30,city:"Paris"},{name:"Bob",age:25,city:"London"},{name:"Carol",age:35,city:"Berlin"}];for(const s of e)await t.set(s);const o=[];for await(const s of t.search({sortBy:"age",order:"asc"}))o.push(s);expect(o.map(s=>s.name)).toEqual(["Bob","Alice","Carol"]);const a=[];for await(const s of t.search({sortBy:"age",order:"asc",limit:2}))a.push(s);expect(a.map(s=>s.name)).toEqual(["Bob","Alice"]);const n=[];for await(const s of t.search({sortBy:"age",order:"asc",offset:1,limit:2}))n.push(s);expect(n.map(s=>s.name)).toEqual(["Alice","Carol"])}),it("should deleteBy where clause",async()=>{await t.set({name:"Dave",age:40,city:"NY"}),await t.set({name:"Eve",age:45,city:"NY"}),await t.set({name:"Frank",age:50,city:"LA"}),expect(await t.count()).toBe(3),await t.deleteBy({city:"NY"}),expect(await t.count()).toBe(1),expect(await t.get("Frank")).toEqual({name:"Frank",age:50,city:"LA"}),expect(await t.get("Dave")).toBeUndefined()}),it("should use selector in get and search",async()=>{await t.set({name:"Gary",age:60,city:"SF"});const e=await t.get("Gary",({age:a})=>a);expect(e).toBe(60);const o=[];for await(const a of t.search({select:({city:n})=>n}))o.push(a);expect(o).toEqual(["SF"])}),it("should delete items by key",async()=>{await t.set({name:"Helen",age:28,city:"Rome"}),expect(await t.get("Helen")).toBeDefined(),await t.delete("Helen"),expect(await t.get("Helen")).toBeUndefined()}),it("should test search with 1000 items",async()=>{const e=[];for(let a=0;a<1e3;a++)e.push({name:`Person${a}`,age:Math.floor(Math.random()*100),city:"City"+a%10});for(const a of e)await t.set(a);const o=[];for await(const a of t.search({sortBy:"age",order:"asc",limit:100}))o.push(a);expect(o.length).toBe(100)}),it("should handle operations on an empty table",async()=>{expect(await t.count()).toBe(0),expect(await t.get("NonExistent")).toBeUndefined();const e=[];for await(const o of t.search({sortBy:"age",order:"asc"}))e.push(o);expect(e.length).toBe(0)}),it("should handle duplicate keys gracefully",async()=>{await t.set({name:"Alice",age:30,city:"Paris"}),await t.set({name:"Alice",age:35,city:"Berlin"});const e=await t.get("Alice");expect(e).toEqual({name:"Alice",age:35,city:"Berlin"})}),it("should handle edge cases in selectors",async()=>{await t.set({name:"Charlie",age:40,city:"NY"});const e=await t.get("Charlie",()=>null);expect(e).toBeNull();const o=await t.get("Charlie",()=>{});expect(o).toBeUndefined()}),it("should clear the table",async()=>{await t.set({name:"Alice",age:30,city:"Paris"}),await t.set({name:"Bob",age:25,city:"London"}),expect(await t.count()).toBe(2),await t.clear(),expect(await t.count()).toBe(0)}),it("should use fts index",async()=>{const e=await c({backend:i,tableName:"TestTableFTS",key:"id",indexes:["fts:content"]});await e.set({id:"1",content:"The \u010Coho brown fox"}),await e.set({id:"2",content:"jumps over the lazy dog"}),await e.set({id:"3",content:"hello world"});const o=[];for await(const n of e.search({where:{content:{fts:["coho","fox"]}}}))o.push(n);expect(o.length).toBe(1),expect(o[0].id).toBe("1");const a=[];for await(const n of e.search({where:{content:{fts:["the"]}}}))a.push(n);expect(a.length).toBe(2)}),it("should use fts index with custom tokenizer options",async()=>{const e=await c({backend:i,tableName:"TestTableFTS2",key:"id",indexes:[{type:"fts",path:"content",tokenizer:{removeDiacritics:0,tokenChars:"abc",separators:"xyz"}}]});await e.set({id:"1",content:"abc xyz"}),await e.set({id:"2",content:"abc"}),await e.set({id:"3",content:"other"});const o=[];for await(const n of e.search({where:{content:{fts:["abc"]}}}))o.push(n);expect(o.length).toBe(2);const a=[];for await(const n of e.search({where:{content:{fts:["xyz"]}}}))a.push(n);expect(a.length).toBe(1),expect(a[0].id).toBe("1")}),it("should support multiple fts fields",async()=>{const e=await c({backend:i,tableName:"TestTableFTSMulti",key:"id",indexes:[{type:"fts",path:"title"},{type:"fts",path:"body"}]});await e.set({id:"1",title:"Hello",body:"World"}),await e.set({id:"2",title:"Foo",body:"Bar"}),await e.set({id:"3",title:"Hello",body:"Bar"});const o=[];for await(const n of e.search({where:{title:{fts:["Hello"]}}}))o.push(n);expect(o.length).toBe(2);const a=[];for await(const n of e.search({where:{body:{fts:["Bar"]}}}))a.push(n);expect(a.length).toBe(2)}),it("should handle fts search with no results",async()=>{const e=await c({backend:i,tableName:"TestTableFTSNone",key:"id",indexes:["fts:content"]});await e.set({id:"1",content:"foo bar"});const o=[];for await(const a of e.search({where:{content:{fts:["notfound"]}}}))o.push(a);expect(o.length).toBe(0)}),it("should custom fn fts index",async()=>{const e=await c({backend:i,tableName:"TestTableFTS",key:"id",indexes:[{type:"fts",path:"content",tokenizer:{removeDiacritics:1}}]});await e.set({id:"1",content:"The \u010Coho brown fox"}),await e.set({id:"2",content:"jumps over the lazy dog"}),await e.set({id:"3",content:"hello world"});const o=[];for await(const n of e.search({where:{content:{fts:["coho","fox"]}}}))o.push(n);expect(o.length).toBe(1),expect(o[0].id).toBe("1");const a=[];for await(const n of e.search({where:{content:{fts:["the"]}}}))a.push(n);expect(a.length).toBe(2)})});
@@ -0,0 +1 @@
1
+ import{unicodeTokenizer as t}from"../table/tokenizer";describe("tokenizer",()=>{const o=[{options:{},expected:'"unicode61"'},{options:{removeDiacritics:1},expected:'"unicode61", "remove_diacritics=1"'},{options:{tokenChars:"abc"},expected:`"unicode61", "tokenchars='abc'"`},{options:{separators:"xyz"},expected:`"unicode61", "separators='xyz'"`},{options:{removeDiacritics:2,tokenChars:"a-b",separators:"x,y"},expected:`"unicode61", "remove_diacritics=2", "tokenchars='a-b'", "separators='x,y'"`},{options:{tokenChars:"a'b",separators:"c'd"},expected:`"unicode61", "tokenchars='a''b'", "separators='c''d'"`},{options:{removeDiacritics:0,tokenChars:"",separators:""},expected:'"unicode61", "remove_diacritics=0"'},{options:{removeDiacritics:1,tokenChars:"abc",separators:"xyz"},expected:`"unicode61", "remove_diacritics=1", "tokenchars='abc'", "separators='xyz'"`}];for(const e of o)it(`returns expected tokenizer string for options: ${JSON.stringify(e.options)}`,()=>{expect(t(e.options)).toBe(e.expected)})});
@@ -1 +1 @@
1
- export*from"./backend";export*from"./table.types";export*from"./where";export*from"./table";export*from"./map-deque";
1
+ export*from"./backend";export*from"./table.types";export*from"./where";export*from"./table";export*from"./map-deque";export*from"./tokenizer";
@@ -1,11 +1,38 @@
1
- import{getWhereQuery as p}from"./where";const O=500,N=100;function D(E){return"$."+E}function I(E,n){if(!(!E||!n))return n.split(".").reduce((r,y)=>{if(typeof r=="object"&&r!==null&&y in r)return r[y]},E)}async function M(E){const{backend:n,tableName:r,indexes:y,key:S,disablePragmaOptimization:$}=E,d=S!==void 0;$||(await n.execute("PRAGMA journal_mode=WAL;"),await n.execute("PRAGMA synchronous=NORMAL;"),await n.execute("PRAGMA temp_store=MEMORY;"),await n.execute("PRAGMA cache_size=-20000;")),d?await n.execute(`
2
- CREATE TABLE IF NOT EXISTS ${r} (
1
+ import{unicodeTokenizer as C}from"./tokenizer";import{getWhereQuery as A}from"./where";const D=500,F=100;function m(l){return"$."+l}function _(l,r){if(!(!l||!r))return r.split(".").reduce((t,y)=>{if(typeof t=="object"&&t!==null&&y in t)return t[y]},l)}async function P(l){const{backend:r,tableName:t,indexes:y,key:R,disablePragmaOptimization:I}=l,d=R!==void 0;I||(await r.execute("PRAGMA journal_mode=WAL;"),await r.execute("PRAGMA synchronous=NORMAL;"),await r.execute("PRAGMA temp_store=MEMORY;"),await r.execute("PRAGMA cache_size=-20000;")),d?await r.execute(`
2
+ CREATE TABLE IF NOT EXISTS ${t} (
3
3
  key TEXT PRIMARY KEY,
4
4
  data TEXT NOT NULL
5
5
  );
6
- `):await n.execute(`
7
- CREATE TABLE IF NOT EXISTS ${r} (
6
+ `):await r.execute(`
7
+ CREATE TABLE IF NOT EXISTS ${t} (
8
8
  data TEXT NOT NULL
9
9
  );
10
- `);for(const t of y??[]){const o=String(t);await n.execute(`CREATE INDEX IF NOT EXISTS idx_${r}_${o.replaceAll(/\W/g,"_")}
11
- ON ${r} (json_extract(data, '${D(o)}'));`)}function k(t){if(d)return I(t,String(S))}async function g(t){return(await t.select("SELECT changes() AS c"))[0]?.c??0}const h={backend:n,async set(t,o){const e=o??n,a=JSON.stringify(t);if(d){const s=k(t);if(s==null)throw new Error(`Document is missing the configured key "${String(S)}". Provide it or create the table without "key".`);if(await e.execute(`UPDATE ${r} SET data = ? WHERE key = ?`,[a,s]),await g(e)===1)return{key:s,op:"update"};try{return await e.execute(`INSERT INTO ${r} (key, data) VALUES (?, ?)`,[s,a]),{key:s,op:"insert"}}catch{return await e.execute(`UPDATE ${r} SET data = ? WHERE key = ?`,[a,s]),{key:s,op:"update"}}}await e.execute(`INSERT INTO ${r} (data) VALUES (?)`,[a]);const u=(await e.select("SELECT last_insert_rowid() AS id"))[0]?.id;if(typeof u!="number")throw new Error("Failed to retrieve last_insert_rowid()");return{key:u,op:"insert"}},async get(t,o=e=>e){const e=d?"key = ?":"rowid = ?",a=await n.select(`SELECT rowid, data FROM ${r} WHERE ${e}`,[t]);if(a.length===0)return;const{data:i,rowid:u}=a[0],s=JSON.parse(i);return o(s,{rowId:u})},async delete(t){const o=d?"key = ?":"rowid = ?";if(await n.execute(`DELETE FROM ${r} WHERE ${o}`,[t]),((await n.select("SELECT changes() AS c"))[0]?.c??0)>0)return{key:t,op:"delete"}},async*search(t={}){const{sortBy:o,order:e="asc",limit:a,offset:i=0,where:u,select:s=l=>l,stepSize:c=N}=t,w=p(u),f=`SELECT rowid, data FROM ${r} ${w}`;let T=0,A=i;for(;;){let l=f;o?l+=` ORDER BY json_extract(data, '${D(String(o))}') COLLATE NOCASE ${e.toUpperCase()}`:l+=d?` ORDER BY key COLLATE NOCASE ${e.toUpperCase()}`:` ORDER BY rowid ${e.toUpperCase()}`;const R=a?Math.min(c,a-T):c;l+=` LIMIT ${R} OFFSET ${A}`;const m=await n.select(l);if(m.length===0)break;for(const{rowid:b,data:L}of m){if(a&&T>=a)return;const x=JSON.parse(L);yield s(x,{rowId:b}),T++}if(m.length<R||a&&T>=a)break;A+=m.length}},async count(t={}){const o=p(t.where),e=`SELECT COUNT(*) as count FROM ${r} ${o}`;return(await n.select(e))[0]?.count??0},async deleteBy(t){const o=p(t),e=d?"key":"rowid",a=[];return await n.transaction(async i=>{const u=await i.select(`SELECT ${e} AS k FROM ${r} ${o}`);if(u.length===0)return;const s=u.map(c=>c.k);for(let c=0;c<s.length;c+=O){const w=s.slice(c,c+O),f=w.map(()=>"?").join(",");await i.execute(`DELETE FROM ${r} WHERE ${e} IN (${f})`,w)}for(const c of s)a.push({key:c,op:"delete"})}),a},async clear(){await n.execute(`DELETE FROM ${r}`)},async batchSet(t){const o=[];return await n.transaction(async e=>{for(const a of t){const i=await h.set(a,e);o.push(i)}}),o}};return h}export{N as DEFAULT_STEP_SIZE,M as createTable,I as getByPath,D as toJsonPath};
10
+ `);let E;const T=[];for(const e of y??[])if(typeof e=="string"&&e.startsWith("fts:"))T.push(e.slice(4));else if(typeof e=="object"&&e.type==="fts"){if(T.push(e.path),e.tokenizer){if(!E)E=e.tokenizer;else if(E!==e.tokenizer)throw new Error(`Conflicting FTS tokenizers: already using "${E}", got "${e.tokenizer}"`)}}else{const a=String(e);await r.execute(`CREATE INDEX IF NOT EXISTS idx_${t}_${a.replaceAll(/\W/g,"_")}
11
+ ON ${t} (json_extract(data, '${m(a)}'));`)}if(T.length>0){let e;typeof E=="object"?e=C(E):E===void 0?e='"unicode61", "remove_diacritics=1"':e=E;const a=T.map(n=>n).join(", "),o=`
12
+ CREATE VIRTUAL TABLE IF NOT EXISTS ${t}_fts
13
+ USING fts5(${a}, tokenize=${e});
14
+ `;await r.execute(o),await r.execute(`
15
+ CREATE TRIGGER IF NOT EXISTS ${t}_ai
16
+ AFTER INSERT ON ${t}
17
+ BEGIN
18
+ INSERT INTO ${t}_fts(rowid, ${a})
19
+ VALUES (
20
+ new.rowid,
21
+ ${T.map(n=>`json_extract(new.data, '${m(n)}')`).join(", ")}
22
+ );
23
+ END;
24
+ `),await r.execute(`
25
+ CREATE TRIGGER IF NOT EXISTS ${t}_ad
26
+ AFTER DELETE ON ${t}
27
+ BEGIN
28
+ DELETE FROM ${t}_fts WHERE rowid = old.rowid;
29
+ END;
30
+ `),await r.execute(`
31
+ CREATE TRIGGER IF NOT EXISTS ${t}_au
32
+ AFTER UPDATE ON ${t}
33
+ BEGIN
34
+ UPDATE ${t}_fts
35
+ SET ${T.map(n=>`${n}=json_extract(new.data, '${m(n)}')`).join(", ")}
36
+ WHERE rowid = old.rowid;
37
+ END;
38
+ `)}function N(e){if(d)return _(e,String(R))}async function g(e){return(await e.select("SELECT changes() AS c"))[0]?.c??0}const h={backend:r,async set(e,a){const o=a??r,n=JSON.stringify(e);if(d){const s=N(e);if(s==null)throw new Error(`Document is missing the configured key "${String(R)}".`);if(await o.execute(`UPDATE ${t} SET data = ? WHERE key = ?`,[n,s]),await g(o)===1)return{key:s,op:"update"};try{return await o.execute(`INSERT INTO ${t} (key, data) VALUES (?, ?)`,[s,n]),{key:s,op:"insert"}}catch{return await o.execute(`UPDATE ${t} SET data = ? WHERE key = ?`,[n,s]),{key:s,op:"update"}}}await o.execute(`INSERT INTO ${t} (data) VALUES (?)`,[n]);const u=(await o.select("SELECT last_insert_rowid() AS id"))[0]?.id;if(typeof u!="number")throw new Error("Failed to retrieve last_insert_rowid()");return{key:u,op:"insert"}},async get(e,a=o=>o){const o=d?"key = ?":"rowid = ?",n=await r.select(`SELECT rowid, data FROM ${t} WHERE ${o}`,[e]);if(n.length===0)return;const{data:c,rowid:u}=n[0],s=JSON.parse(c);return a(s,{rowId:u})},async delete(e){const a=d?"key = ?":"rowid = ?";if(await r.execute(`DELETE FROM ${t} WHERE ${a}`,[e]),((await r.select("SELECT changes() AS c"))[0]?.c??0)>0)return{key:e,op:"delete"}},async*search(e={}){const{sortBy:a,order:o="asc",limit:n,offset:c=0,where:u,select:s=w=>w,stepSize:i=F}=e,f=A(u,t),$=`SELECT rowid, data FROM ${t} ${f}`;let S=0,O=c;for(;;){let w=$;a?w+=` ORDER BY json_extract(data, '${m(String(a))}') COLLATE NOCASE ${o.toUpperCase()}`:w+=d?` ORDER BY key COLLATE NOCASE ${o.toUpperCase()}`:` ORDER BY rowid ${o.toUpperCase()}`;const k=n?Math.min(i,n-S):i;w+=` LIMIT ${k} OFFSET ${O}`;const p=await r.select(w);if(p.length===0)break;for(const{rowid:L,data:x}of p){if(n&&S>=n)return;const b=JSON.parse(x);yield s(b,{rowId:L}),S++}if(p.length<k||n&&S>=n)break;O+=p.length}},async count(e={}){const a=A(e.where,t),o=`SELECT COUNT(*) as count FROM ${t} ${a}`;return(await r.select(o))[0]?.count??0},async deleteBy(e){const a=A(e,t),o=d?"key":"rowid",n=[];return await r.transaction(async c=>{const u=await c.select(`SELECT ${o} AS k FROM ${t} ${a}`);if(u.length===0)return;const s=u.map(i=>i.k);for(let i=0;i<s.length;i+=D){const f=s.slice(i,i+D),$=f.map(()=>"?").join(",");await c.execute(`DELETE FROM ${t} WHERE ${o} IN (${$})`,f)}for(const i of s)n.push({key:i,op:"delete"})}),n},async clear(){await r.execute(`DELETE FROM ${t}`)},async batchSet(e){const a=[];return await r.transaction(async o=>{for(const n of e){const c=await h.set(n,o);a.push(c)}}),a}};return h}export{F as DEFAULT_STEP_SIZE,P as createTable,_ as getByPath,m as toJsonPath};
@@ -0,0 +1 @@
1
+ function o(i={}){const{removeDiacritics:t,tokenChars:r,separators:n}=i,e=[];return t!==void 0&&e.push(`"remove_diacritics=${t}"`),r&&r.length>0&&e.push(`"tokenchars='${r.replaceAll("'","''")}'"`),n&&n.length>0&&e.push(`"separators='${n.replaceAll("'","''")}'"`),e.length===0||!(e.length>0)?'"unicode61"':`"unicode61", ${e.join(", ")}`}export{o as unicodeTokenizer};
@@ -1 +1 @@
1
- function l(n){return typeof n=="string"?`'${n.replaceAll("'","''")}'`:typeof n=="number"?n.toString():typeof n=="boolean"?n?"1":"0":`'${String(n).replaceAll("'","''")}'`}function g(n,e,u){const t=u?`${u}.`:"";return n==="KEY"?`"${t}key"`:typeof e=="string"?`CAST(json_extract(${t}data, '$.${n}') AS TEXT)`:typeof e=="number"?`CAST(json_extract(${t}data, '$.${n}') AS NUMERIC)`:typeof e=="boolean"?`CAST(json_extract(${t}data, '$.${n}') AS INTEGER)`:`json_extract(${t}data, '$.${n}')`}const A=new Set(["is","isNot","gt","gte","lt","lte","in","notIn","like"]);function N(n,e=""){const u={};for(const[t,o]of Object.entries(n)){if(t==="AND"||t==="OR"||t==="NOT"){u[t]=o;continue}const r=e?`${e}.${t}`:t;o&&typeof o=="object"&&!Array.isArray(o)&&!Object.keys(o).some(i=>A.has(i))?Object.assign(u,N(o,r)):u[r]=o}return u}function T(n,e){if(!n||typeof n!="object")return"";if(n.AND){const r=Array.isArray(n.AND)?n.AND.map(i=>T(i,e)).filter(Boolean):[];return r.length>0?`(${r.join(" AND ")})`:""}if(n.OR){const r=Array.isArray(n.OR)?n.OR.map(i=>T(i,e)).filter(Boolean):[];return r.length>0?`(${r.join(" OR ")})`:""}if(n.NOT){const r=T(n.NOT,e);return r?`(NOT ${r})`:""}const u=N(n);let t="",o=!1;for(const[r,i]of Object.entries(u)){if(i==null)continue;let y;typeof i!="object"||Array.isArray(i)?y=Array.isArray(i)?{in:i}:{is:i}:y=i;for(const s of Object.keys(y)){if(!A.has(s))continue;const $=y[s];if($==null)continue;const f=Array.isArray($)?$:[$];if(f.length!==0){if(s==="is"||s==="isNot"||s==="in"||s==="notIn"){const c=g(r,f[0],e),a=f.map(l).join(","),d=s==="is"?f.length>1?`${c} IN (${a})`:`${c} = ${l(f[0])}`:s==="isNot"?f.length>1?`${c} NOT IN (${a})`:`${c} <> ${l(f[0])}`:s==="in"?`${c} IN (${a})`:`${c} NOT IN (${a})`;t+=(o?" AND ":"")+d,o=!0;continue}for(const c of f){const a=g(r,c,e),d=s==="gt"?`${a} > ${l(c)}`:s==="gte"?`${a} >= ${l(c)}`:s==="lt"?`${a} < ${l(c)}`:s==="lte"?`${a} <= ${l(c)}`:`${a} LIKE ${l(c)}`;t+=(o?" AND ":"")+d,o=!0}}}}return o?`(${t})`:""}function p(n){if(!n)return"";const e=T(n);return e?`WHERE ${e}`:""}export{T as getWhere,p as getWhereQuery};
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};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muya",
3
- "version": "2.3.3",
3
+ "version": "2.3.5",
4
4
  "author": "samuel.gjabel@gmail.com",
5
5
  "repository": "https://github.com/samuelgjabel/muya",
6
6
  "main": "cjs/index.js",
@@ -1,9 +1,6 @@
1
1
  /* eslint-disable @typescript-eslint/no-shadow */
2
2
  /* eslint-disable no-shadow */
3
3
  /* eslint-disable sonarjs/pseudo-random */
4
- // /* eslint-disable sonarjs/no-unused-vars */
5
- // /* eslint-disable unicorn/prevent-abbreviations */
6
-
7
4
  import { bunMemoryBackend } from '../table/bun-backend'
8
5
  import { createTable } from '../table/table'
9
6
 
@@ -178,4 +175,146 @@ describe('table', () => {
178
175
  const undefinedSelector = await table.get('Charlie', () => void 0)
179
176
  expect(undefinedSelector).toBeUndefined()
180
177
  })
178
+
179
+ it('should clear the table', async () => {
180
+ await table.set({ name: 'Alice', age: 30, city: 'Paris' })
181
+ await table.set({ name: 'Bob', age: 25, city: 'London' })
182
+ expect(await table.count()).toBe(2)
183
+ await table.clear()
184
+ expect(await table.count()).toBe(0)
185
+ })
186
+ it('should use fts index', async () => {
187
+ const tableFts = await createTable<{ id: string; content: string }>({
188
+ backend,
189
+ tableName: 'TestTableFTS',
190
+ key: 'id',
191
+ indexes: ['fts:content'],
192
+ })
193
+ await tableFts.set({ id: '1', content: 'The Čoho brown fox' })
194
+ await tableFts.set({ id: '2', content: 'jumps over the lazy dog' })
195
+ await tableFts.set({ id: '3', content: 'hello world' })
196
+
197
+ const results: { id: string; content: string }[] = []
198
+ for await (const doc of tableFts.search({ where: { content: { fts: ['coho', 'fox'] } } })) {
199
+ results.push(doc)
200
+ }
201
+ expect(results.length).toBe(1)
202
+ expect(results[0].id).toBe('1')
203
+
204
+ const results2: { id: string; content: string }[] = []
205
+ for await (const doc of tableFts.search({ where: { content: { fts: ['the'] } } })) {
206
+ results2.push(doc)
207
+ }
208
+ expect(results2.length).toBe(2)
209
+ })
210
+
211
+ it('should use fts index with custom tokenizer options', async () => {
212
+ // Use only ASCII letters for tokenchars/separators to avoid SQLite errors
213
+ const tableFts = await createTable<{ id: string; content: string }>({
214
+ backend,
215
+ tableName: 'TestTableFTS2',
216
+ key: 'id',
217
+ indexes: [
218
+ {
219
+ type: 'fts',
220
+ path: 'content',
221
+ tokenizer: { removeDiacritics: 0, tokenChars: 'abc', separators: 'xyz' },
222
+ },
223
+ ],
224
+ })
225
+ await tableFts.set({ id: '1', content: 'abc xyz' })
226
+ await tableFts.set({ id: '2', content: 'abc' })
227
+ await tableFts.set({ id: '3', content: 'other' })
228
+
229
+ const results: { id: string; content: string }[] = []
230
+ for await (const doc of tableFts.search({ where: { content: { fts: ['abc'] } } })) {
231
+ results.push(doc)
232
+ }
233
+ expect(results.length).toBe(2)
234
+
235
+ const results2: { id: string; content: string }[] = []
236
+ for await (const doc of tableFts.search({ where: { content: { fts: ['xyz'] } } })) {
237
+ results2.push(doc)
238
+ }
239
+ expect(results2.length).toBe(1)
240
+ expect(results2[0].id).toBe('1')
241
+ })
242
+
243
+ it('should support multiple fts fields', async () => {
244
+ // Use a single FTS index with multiple fields (title, body) in one index
245
+ const tableFts = await createTable<{ id: string; title: string; body: string }>({
246
+ backend,
247
+ tableName: 'TestTableFTSMulti',
248
+ key: 'id',
249
+ indexes: [
250
+ { type: 'fts', path: 'title' },
251
+ { type: 'fts', path: 'body' },
252
+ ],
253
+ })
254
+ await tableFts.set({ id: '1', title: 'Hello', body: 'World' })
255
+ await tableFts.set({ id: '2', title: 'Foo', body: 'Bar' })
256
+ await tableFts.set({ id: '3', title: 'Hello', body: 'Bar' })
257
+
258
+ // FTS search on title
259
+ const results: { id: string; title: string; body: string }[] = []
260
+ for await (const doc of tableFts.search({ where: { title: { fts: ['Hello'] } } })) {
261
+ results.push(doc)
262
+ }
263
+ expect(results.length).toBe(2)
264
+
265
+ // FTS search on body
266
+ const results2: { id: string; title: string; body: string }[] = []
267
+ for await (const doc of tableFts.search({ where: { body: { fts: ['Bar'] } } })) {
268
+ results2.push(doc)
269
+ }
270
+ expect(results2.length).toBe(2)
271
+ })
272
+
273
+ it('should handle fts search with no results', async () => {
274
+ const tableFts = await createTable<{ id: string; content: string }>({
275
+ backend,
276
+ tableName: 'TestTableFTSNone',
277
+ key: 'id',
278
+ indexes: ['fts:content'],
279
+ })
280
+ await tableFts.set({ id: '1', content: 'foo bar' })
281
+ const results: { id: string; content: string }[] = []
282
+ for await (const doc of tableFts.search({ where: { content: { fts: ['notfound'] } } })) {
283
+ results.push(doc)
284
+ }
285
+ expect(results.length).toBe(0)
286
+ })
287
+
288
+ it('should custom fn fts index', async () => {
289
+ const tableFts = await createTable<{ id: string; content: string }>({
290
+ backend,
291
+ tableName: 'TestTableFTS',
292
+ key: 'id',
293
+ indexes: [
294
+ {
295
+ type: 'fts',
296
+ path: 'content',
297
+ tokenizer: {
298
+ removeDiacritics: 1,
299
+ },
300
+ },
301
+ ],
302
+ })
303
+ await tableFts.set({ id: '1', content: 'The Čoho brown fox' })
304
+ await tableFts.set({ id: '2', content: 'jumps over the lazy dog' })
305
+ await tableFts.set({ id: '3', content: 'hello world' })
306
+
307
+ const results: { id: string; content: string }[] = []
308
+ for await (const doc of tableFts.search({ where: { content: { fts: ['coho', 'fox'] } } })) {
309
+ results.push(doc)
310
+ }
311
+ expect(results.length).toBe(1)
312
+ expect(results[0].id).toBe('1')
313
+
314
+ const results2: { id: string; content: string }[] = []
315
+ for await (const doc of tableFts.search({ where: { content: { fts: ['the'] } } })) {
316
+ results2.push(doc)
317
+ }
318
+ expect(results2.length).toBe(2)
319
+ })
181
320
  })
@@ -0,0 +1,43 @@
1
+ import { unicodeTokenizer, type FtsTokenizerOptions } from '../table/tokenizer'
2
+
3
+ describe('tokenizer', () => {
4
+ const items: {
5
+ options: FtsTokenizerOptions
6
+ expected: string
7
+ }[] = [
8
+ { options: {}, expected: '"unicode61"' },
9
+ {
10
+ options: { removeDiacritics: 1 },
11
+ expected: '"unicode61", "remove_diacritics=1"',
12
+ },
13
+ {
14
+ options: { tokenChars: 'abc' },
15
+ expected: '"unicode61", "tokenchars=\'abc\'"',
16
+ },
17
+ {
18
+ options: { separators: 'xyz' },
19
+ expected: '"unicode61", "separators=\'xyz\'"',
20
+ },
21
+ {
22
+ options: { removeDiacritics: 2, tokenChars: 'a-b', separators: 'x,y' },
23
+ expected: '"unicode61", "remove_diacritics=2", "tokenchars=\'a-b\'", "separators=\'x,y\'"',
24
+ },
25
+ {
26
+ options: { tokenChars: "a'b", separators: "c'd" },
27
+ expected: "\"unicode61\", \"tokenchars='a''b'\", \"separators='c''d'\"",
28
+ },
29
+ {
30
+ options: { removeDiacritics: 0, tokenChars: '', separators: '' },
31
+ expected: '"unicode61", "remove_diacritics=0"',
32
+ },
33
+ {
34
+ options: { removeDiacritics: 1, tokenChars: 'abc', separators: 'xyz' },
35
+ expected: '"unicode61", "remove_diacritics=1", "tokenchars=\'abc\'", "separators=\'xyz\'"',
36
+ },
37
+ ]
38
+ for (const item of items) {
39
+ it(`returns expected tokenizer string for options: ${JSON.stringify(item.options)}`, () => {
40
+ expect(unicodeTokenizer(item.options)).toBe(item.expected)
41
+ })
42
+ }
43
+ })
@@ -3,3 +3,4 @@ export * from './table.types'
3
3
  export * from './where'
4
4
  export * from './table'
5
5
  export * from './map-deque'
6
+ export * from './tokenizer'
@@ -3,18 +3,18 @@
3
3
  /* eslint-disable sonarjs/cognitive-complexity */
4
4
  /* eslint-disable @typescript-eslint/no-shadow */
5
5
  /* eslint-disable no-shadow */
6
-
7
6
  import type { Table, DbOptions, DocType, Key, SearchOptions, MutationResult } from './table.types'
7
+ import { unicodeTokenizer, type FtsTokenizerOptions } from './tokenizer'
8
8
  import type { Where } from './where'
9
9
  import { getWhereQuery } from './where'
10
10
 
11
- const DELETE_IN_CHUNK = 500 // keep well below SQLite's default 999 parameter limit
11
+ const DELETE_IN_CHUNK = 500
12
12
  export const DEFAULT_STEP_SIZE = 100
13
13
 
14
14
  /**
15
15
  * Convert a dot-separated path to a JSON path
16
- * @param dot The dot-separated path
17
- * @returns The JSON path
16
+ * @param dot The dot-separated path string
17
+ * @returns The JSON path string
18
18
  */
19
19
  export function toJsonPath(dot: string) {
20
20
  return '$.' + dot
@@ -22,8 +22,8 @@ export function toJsonPath(dot: string) {
22
22
 
23
23
  /**
24
24
  * Get a nested value from an object using a dot-separated path
25
- * @param object The object to get the value from
26
- * @param path The dot-separated path to the value
25
+ * @param object The object to retrieve the value from
26
+ * @param path The dot-separated path string
27
27
  * @returns The value at the specified path, or undefined if not found
28
28
  */
29
29
  export function getByPath<T extends object>(object: T, path: string): unknown {
@@ -38,15 +38,14 @@ export function getByPath<T extends object>(object: T, path: string): unknown {
38
38
  }
39
39
 
40
40
  /**
41
- * Create a new table in the database with the given options
42
- * @param options The options for creating the table
43
- * @returns The created table
41
+ * Create and initialize a table in the database with the specified options
42
+ * @param options The options for creating the table, including table name, indexes, backend, and key
43
+ * @returns A promise that resolves to the created Table instance
44
44
  */
45
45
  export async function createTable<Document extends DocType>(options: DbOptions<Document>): Promise<Table<Document>> {
46
46
  const { backend, tableName, indexes, key, disablePragmaOptimization } = options
47
47
  const hasUserKey = key !== undefined
48
48
 
49
- // --- Apply performance PRAGMAs unless explicitly disabled ---
50
49
  if (!disablePragmaOptimization) {
51
50
  await backend.execute(`PRAGMA journal_mode=WAL;`)
52
51
  await backend.execute(`PRAGMA synchronous=NORMAL;`)
@@ -54,7 +53,7 @@ export async function createTable<Document extends DocType>(options: DbOptions<D
54
53
  await backend.execute(`PRAGMA cache_size=-20000;`)
55
54
  }
56
55
 
57
- // Schema
56
+ // Base JSON table
58
57
  if (hasUserKey) {
59
58
  await backend.execute(`
60
59
  CREATE TABLE IF NOT EXISTS ${tableName} (
@@ -70,19 +69,89 @@ export async function createTable<Document extends DocType>(options: DbOptions<D
70
69
  `)
71
70
  }
72
71
 
73
- // JSON expression indexes
72
+ // Track FTS fields
73
+ let ftsTokenizer: string | undefined | FtsTokenizerOptions
74
+ const ftsFields: string[] = []
75
+
74
76
  for (const index of indexes ?? []) {
75
- const idx = String(index)
76
- await backend.execute(
77
- `CREATE INDEX IF NOT EXISTS idx_${tableName}_${idx.replaceAll(/\W/g, '_')}
77
+ if (typeof index === 'string' && index.startsWith('fts:')) {
78
+ ftsFields.push(index.slice(4))
79
+ } else if (typeof index === 'object' && index.type === 'fts') {
80
+ ftsFields.push(index.path)
81
+ if (index.tokenizer) {
82
+ if (!ftsTokenizer) {
83
+ ftsTokenizer = index.tokenizer
84
+ } else if (ftsTokenizer !== index.tokenizer) {
85
+ throw new Error(`Conflicting FTS tokenizers: already using "${ftsTokenizer}", got "${index.tokenizer}"`)
86
+ }
87
+ }
88
+ } else {
89
+ const idx = String(index)
90
+ await backend.execute(
91
+ `CREATE INDEX IF NOT EXISTS idx_${tableName}_${idx.replaceAll(/\W/g, '_')}
78
92
  ON ${tableName} (json_extract(data, '${toJsonPath(idx)}'));`,
79
- )
93
+ )
94
+ }
95
+ }
96
+
97
+ // Create FTS table + triggers
98
+ if (ftsFields.length > 0) {
99
+ // const tokenizerSpec = ftsTokenizer ?? '"unicode61", "remove_diacritics=1"'
100
+ let tokenizerSpec: string
101
+ if (typeof ftsTokenizer === 'object') {
102
+ tokenizerSpec = unicodeTokenizer(ftsTokenizer)
103
+ } else if (ftsTokenizer === undefined) {
104
+ tokenizerSpec = '"unicode61", "remove_diacritics=1"'
105
+ } else {
106
+ tokenizerSpec = ftsTokenizer
107
+ }
108
+ // Use actual field names for FTS columns
109
+ const ftsColumns = ftsFields.map((f) => f).join(', ')
110
+ const query = `
111
+ CREATE VIRTUAL TABLE IF NOT EXISTS ${tableName}_fts
112
+ USING fts5(${ftsColumns}, tokenize=${tokenizerSpec});
113
+ `
114
+
115
+ await backend.execute(query)
116
+
117
+ // Insert trigger
118
+ await backend.execute(`
119
+ CREATE TRIGGER IF NOT EXISTS ${tableName}_ai
120
+ AFTER INSERT ON ${tableName}
121
+ BEGIN
122
+ INSERT INTO ${tableName}_fts(rowid, ${ftsColumns})
123
+ VALUES (
124
+ new.rowid,
125
+ ${ftsFields.map((f) => `json_extract(new.data, '${toJsonPath(f)}')`).join(', ')}
126
+ );
127
+ END;
128
+ `)
129
+
130
+ // Delete trigger
131
+ await backend.execute(`
132
+ CREATE TRIGGER IF NOT EXISTS ${tableName}_ad
133
+ AFTER DELETE ON ${tableName}
134
+ BEGIN
135
+ DELETE FROM ${tableName}_fts WHERE rowid = old.rowid;
136
+ END;
137
+ `)
138
+
139
+ // Update trigger
140
+ await backend.execute(`
141
+ CREATE TRIGGER IF NOT EXISTS ${tableName}_au
142
+ AFTER UPDATE ON ${tableName}
143
+ BEGIN
144
+ UPDATE ${tableName}_fts
145
+ SET ${ftsFields.map((f) => `${f}=json_extract(new.data, '${toJsonPath(f)}')`).join(', ')}
146
+ WHERE rowid = old.rowid;
147
+ END;
148
+ `)
80
149
  }
81
150
 
82
151
  /**
83
- * Get the key value from a document
152
+ * Get the value of the configured key from a document
84
153
  * @param document The document to extract the key from
85
- * @returns The key value or undefined if no user key is configured
154
+ * @returns The value of the key, or undefined if not found or no key is configured
86
155
  */
87
156
  function getKeyFromDocument(document: Document): Key | undefined {
88
157
  if (!hasUserKey) return undefined
@@ -90,9 +159,9 @@ export async function createTable<Document extends DocType>(options: DbOptions<D
90
159
  }
91
160
 
92
161
  /**
93
- * Get the number of rows changed by the last operation
94
- * @param conn The database connection
95
- * @returns The number of rows changed
162
+ * Get the number of rows changed by the last operation on the given connection
163
+ * @param conn The database connection to check for changes
164
+ * @returns A promise that resolves to the number of changed rows
96
165
  */
97
166
  async function getChanges(conn: typeof backend): Promise<number> {
98
167
  const r = await conn.select<Array<{ c: number }>>(`SELECT changes() AS c`)
@@ -109,17 +178,13 @@ export async function createTable<Document extends DocType>(options: DbOptions<D
109
178
  if (hasUserKey) {
110
179
  const id = getKeyFromDocument(document)
111
180
  if (id === undefined || id === null) {
112
- throw new Error(
113
- `Document is missing the configured key "${String(key)}". Provide it or create the table without "key".`,
114
- )
181
+ throw new Error(`Document is missing the configured key "${String(key)}".`)
115
182
  }
116
183
 
117
- // Fast path: UPDATE first
118
184
  await db.execute(`UPDATE ${tableName} SET data = ? WHERE key = ?`, [json, id])
119
185
  const updated = await getChanges(db)
120
186
  if (updated === 1) return { key: id, op: 'update' }
121
187
 
122
- // No row updated => try INSERT
123
188
  try {
124
189
  await db.execute(`INSERT INTO ${tableName} (key, data) VALUES (?, ?)`, [id, json])
125
190
  return { key: id, op: 'insert' }
@@ -129,7 +194,6 @@ export async function createTable<Document extends DocType>(options: DbOptions<D
129
194
  }
130
195
  }
131
196
 
132
- // ROWID mode
133
197
  await db.execute(`INSERT INTO ${tableName} (data) VALUES (?)`, [json])
134
198
  const rows = await db.select<Array<{ id: number }>>(`SELECT last_insert_rowid() AS id`)
135
199
  const rowid = rows[0]?.id
@@ -139,7 +203,6 @@ export async function createTable<Document extends DocType>(options: DbOptions<D
139
203
 
140
204
  async get<Selected = Document>(
141
205
  keyValue: Key,
142
- // keep meta available and consistent with search() { rowId }
143
206
  selector: (document: Document, meta: { rowId: number }) => Selected = (d) => d as unknown as Selected,
144
207
  ) {
145
208
  const whereKey = hasUserKey ? `key = ?` : `rowid = ?`
@@ -172,7 +235,7 @@ export async function createTable<Document extends DocType>(options: DbOptions<D
172
235
  stepSize = DEFAULT_STEP_SIZE,
173
236
  } = options
174
237
 
175
- const whereSql = getWhereQuery<Document>(where)
238
+ const whereSql = getWhereQuery<Document>(where, tableName)
176
239
  const baseQuery = `SELECT rowid, data FROM ${tableName} ${whereSql}`
177
240
 
178
241
  let yielded = 0
@@ -205,14 +268,14 @@ export async function createTable<Document extends DocType>(options: DbOptions<D
205
268
  },
206
269
 
207
270
  async count(options: { where?: Where<Document> } = {}) {
208
- const whereSql = getWhereQuery<Document>(options.where)
271
+ const whereSql = getWhereQuery<Document>(options.where, tableName)
209
272
  const query = `SELECT COUNT(*) as count FROM ${tableName} ${whereSql}`
210
273
  const result = await backend.select<Array<{ count: number }>>(query)
211
274
  return result[0]?.count ?? 0
212
275
  },
213
276
 
214
277
  async deleteBy(where: Where<Document>) {
215
- const whereSql = getWhereQuery<Document>(where)
278
+ const whereSql = getWhereQuery<Document>(where, tableName)
216
279
  const keyCol = hasUserKey ? 'key' : 'rowid'
217
280
  const results: MutationResult[] = []
218
281
 
@@ -236,6 +299,7 @@ export async function createTable<Document extends DocType>(options: DbOptions<D
236
299
  async clear() {
237
300
  await backend.execute(`DELETE FROM ${tableName}`)
238
301
  },
302
+
239
303
  async batchSet(documents: Document[]) {
240
304
  const mutations: MutationResult[] = []
241
305
  await backend.transaction(async (tx) => {
@@ -1,6 +1,6 @@
1
- // table.types.ts
2
1
  import type { SqlSeachOptions } from '../select-sql'
3
2
  import type { Backend } from './backend'
3
+ import type { FtsTokenizerOptions } from './tokenizer'
4
4
  import type { Where } from './where'
5
5
 
6
6
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -19,10 +19,28 @@ export type DotPath<T, D extends number = 5> = [D] extends [never]
19
19
  [K in Extract<keyof T, string>]: T[K] extends object ? K | `${K}.${DotPath<T[K], Previous[D]>}` : K
20
20
  }[Extract<keyof T, string>]
21
21
  : never
22
- // Replace keyof Document with DotPath<Document>
22
+
23
+ // Built-in FTS5 tokenizers
24
+ export type FtsTokenizer =
25
+ | 'porter' // English stemming
26
+ | 'simple' // basic ASCII tokenizer
27
+ | 'icu' // requires SQLite compiled with ICU extension
28
+ | 'unicode61' // Unicode-aware tokenizer with diacritic removal and custom token chars
29
+ | FtsTokenizerOptions
30
+
31
+ export interface FtsType<Document extends DocType> {
32
+ readonly type: 'fts'
33
+ readonly path: DotPath<Document>
34
+ readonly tokenizer?: FtsTokenizer
35
+ }
36
+ export type IndexDeclaration<Document extends DocType> =
37
+ | DotPath<Document> // normal JSON path index
38
+ | `fts:${DotPath<Document>}` // full-text index
39
+ | FtsType<Document> // full-text index with options
40
+
23
41
  export interface DbOptions<Document extends DocType> {
24
42
  readonly tableName: string
25
- readonly indexes?: Array<DotPath<Document>>
43
+ readonly indexes?: Array<IndexDeclaration<Document>>
26
44
  readonly backend: Backend
27
45
  readonly key?: DotPath<Document>
28
46
  readonly disablePragmaOptimization?: boolean
@@ -0,0 +1,35 @@
1
+ export interface FtsTokenizerOptions {
2
+ readonly removeDiacritics?: 0 | 1 | 2
3
+ readonly tokenChars?: string
4
+ readonly separators?: string
5
+ }
6
+
7
+ /**
8
+ * Create a custom FTS5 tokenizer string based on the provided options
9
+ * @param options Options to customize the tokenizer
10
+ * @returns A string representing the FTS5 tokenizer configuration
11
+ */
12
+ export function unicodeTokenizer(options: FtsTokenizerOptions = {}): string {
13
+ const { removeDiacritics, tokenChars, separators } = options
14
+ const parts: string[] = []
15
+
16
+ if (removeDiacritics !== undefined) {
17
+ parts.push(`"remove_diacritics=${removeDiacritics}"`)
18
+ }
19
+ if (tokenChars && tokenChars.length > 0) {
20
+ parts.push(`"tokenchars='${tokenChars.replaceAll("'", "''")}'"`)
21
+ }
22
+ if (separators && separators.length > 0) {
23
+ parts.push(`"separators='${separators.replaceAll("'", "''")}'"`)
24
+ }
25
+
26
+ // 👉 return correct SQLite syntax
27
+ if (parts.length === 0) {
28
+ return '"unicode61"'
29
+ }
30
+ const hasParts = parts.length > 0
31
+ if (!hasParts) {
32
+ return '"unicode61"'
33
+ }
34
+ return `"unicode61", ${parts.join(', ')}`
35
+ }
@@ -17,6 +17,7 @@ interface Condition<T> {
17
17
  readonly in?: T[]
18
18
  readonly notIn?: T[]
19
19
  readonly like?: T | T[]
20
+ readonly fts?: string | string[] // 🔥 NEW
20
21
  }
21
22
 
22
23
  // -------------------------------------------------------------
@@ -55,16 +56,13 @@ function inlineValue(value: unknown): string {
55
56
  */
56
57
  function getFieldExpr(field: string, value: unknown, tableAlias?: string): string {
57
58
  const prefix = tableAlias ? `${tableAlias}.` : ''
58
- if (field === 'KEY') {
59
- return `"${prefix}key"`
60
- }
59
+ if (field === 'KEY') return `"${prefix}key"`
61
60
  if (typeof value === 'string') return `CAST(json_extract(${prefix}data, '$.${field}') AS TEXT)`
62
61
  if (typeof value === 'number') return `CAST(json_extract(${prefix}data, '$.${field}') AS NUMERIC)`
63
62
  if (typeof value === 'boolean') return `CAST(json_extract(${prefix}data, '$.${field}') AS INTEGER)`
64
63
  return `json_extract(${prefix}data, '$.${field}')`
65
64
  }
66
-
67
- const OPS_SET: ReadonlySet<string> = new Set(['is', 'isNot', 'gt', 'gte', 'lt', 'lte', 'in', 'notIn', 'like'])
65
+ const OPS_SET: ReadonlySet<string> = new Set(['is', 'isNot', 'gt', 'gte', 'lt', 'lte', 'in', 'notIn', 'like', 'fts'])
68
66
 
69
67
  /**
70
68
  * Flatten a nested Where object into a single-level object with dot-separated keys
@@ -97,28 +95,27 @@ function flattenWhere(object: Record<string, unknown>, prefix = ''): Record<stri
97
95
  * Write SQL WHERE clause from a Where object
98
96
  * @param where The Where object defining the conditions
99
97
  * @param tableAlias Optional table alias to prefix field names
98
+ * @param tableName Optional table name (required for FTS conditions)
100
99
  * @returns The SQL WHERE clause string (without the "WHERE" keyword)
101
100
  */
102
- export function getWhere<T extends Record<string, unknown>>(where: Where<T>, tableAlias?: string): string {
101
+ export function getWhere<T extends Record<string, unknown>>(where: Where<T>, tableAlias?: string, tableName?: string): string {
103
102
  if (!where || typeof where !== 'object') return ''
104
103
 
105
- // ----- Logical branches -----
106
104
  if (where.AND) {
107
- const clauses = Array.isArray(where.AND) ? where.AND.map((w) => getWhere(w, tableAlias)).filter(Boolean) : []
105
+ const clauses = Array.isArray(where.AND) ? where.AND.map((w) => getWhere(w, tableAlias, tableName)).filter(Boolean) : []
108
106
  return clauses.length > 0 ? `(${clauses.join(' AND ')})` : ''
109
107
  }
110
108
 
111
109
  if (where.OR) {
112
- const clauses = Array.isArray(where.OR) ? where.OR.map((w) => getWhere(w, tableAlias)).filter(Boolean) : []
110
+ const clauses = Array.isArray(where.OR) ? where.OR.map((w) => getWhere(w, tableAlias, tableName)).filter(Boolean) : []
113
111
  return clauses.length > 0 ? `(${clauses.join(' OR ')})` : ''
114
112
  }
115
113
 
116
114
  if (where.NOT) {
117
- const clause = getWhere(where.NOT, tableAlias)
115
+ const clause = getWhere(where.NOT, tableAlias, tableName)
118
116
  return clause ? `(NOT ${clause})` : ''
119
117
  }
120
118
 
121
- // ----- Field conditions -----
122
119
  const flat = flattenWhere(where as Record<string, unknown>)
123
120
  let fieldClauses = ''
124
121
  let anyField = false
@@ -126,7 +123,6 @@ export function getWhere<T extends Record<string, unknown>>(where: Where<T>, tab
126
123
  for (const [key, rawValue] of Object.entries(flat)) {
127
124
  if (rawValue == null) continue
128
125
 
129
- // coerce primitive/array into Condition
130
126
  let cond: Condition<unknown>
131
127
  if (typeof rawValue !== 'object' || Array.isArray(rawValue)) {
132
128
  cond = Array.isArray(rawValue) ? { in: rawValue } : { is: rawValue }
@@ -135,18 +131,28 @@ export function getWhere<T extends Record<string, unknown>>(where: Where<T>, tab
135
131
  }
136
132
 
137
133
  for (const opKey of Object.keys(cond)) {
138
- if (!OPS_SET.has(opKey)) continue
139
134
  const opValue = cond[opKey as keyof Condition<unknown>]
140
135
  if (opValue == null) continue
141
136
 
142
137
  const values = Array.isArray(opValue) ? opValue : [opValue]
143
138
  if (values.length === 0) continue
144
139
 
145
- // is / isNot / in / notIn
140
+ if (opKey === 'fts') {
141
+ if (!tableName) throw new Error('FTS requires tableName for JOIN reference')
142
+ const clause = values
143
+ .map(
144
+ (v) =>
145
+ `EXISTS (SELECT 1 FROM ${tableName}_fts f WHERE f.rowid = ${tableAlias ?? tableName}.rowid AND ${tableName}_fts MATCH ${inlineValue(v)})`,
146
+ )
147
+ .join(' AND ')
148
+ fieldClauses += (anyField ? ' AND ' : '') + clause
149
+ anyField = true
150
+ continue
151
+ }
152
+
146
153
  if (opKey === 'is' || opKey === 'isNot' || opKey === 'in' || opKey === 'notIn') {
147
154
  const fieldExpr = getFieldExpr(key, values[0], tableAlias)
148
155
  const inList = values.map(inlineValue).join(',')
149
-
150
156
  const clause =
151
157
  opKey === 'is'
152
158
  ? values.length > 1
@@ -159,13 +165,11 @@ export function getWhere<T extends Record<string, unknown>>(where: Where<T>, tab
159
165
  : opKey === 'in'
160
166
  ? `${fieldExpr} IN (${inList})`
161
167
  : `${fieldExpr} NOT IN (${inList})`
162
-
163
168
  fieldClauses += (anyField ? ' AND ' : '') + clause
164
169
  anyField = true
165
170
  continue
166
171
  }
167
172
 
168
- // gt / gte / lt / lte / like
169
173
  for (const v of values) {
170
174
  const fieldExpr = getFieldExpr(key, v, tableAlias)
171
175
  const clause =
@@ -190,10 +194,11 @@ export function getWhere<T extends Record<string, unknown>>(where: Where<T>, tab
190
194
  /**
191
195
  * Get SQL WHERE clause from a Where object
192
196
  * @param where The Where object defining the conditions
197
+ * @param tableName Optional table name (required for FTS conditions)
193
198
  * @returns The SQL WHERE clause string (without the "WHERE" keyword)
194
199
  */
195
- export function getWhereQuery<T extends Record<string, unknown>>(where?: Where<T>): string {
200
+ export function getWhereQuery<T extends Record<string, unknown>>(where?: Where<T>, tableName?: string): string {
196
201
  if (!where) return ''
197
- const clause = getWhere(where)
202
+ const clause = getWhere(where, undefined, tableName)
198
203
  return clause ? `WHERE ${clause}` : ''
199
204
  }
@@ -3,3 +3,4 @@ export * from './table.types';
3
3
  export * from './where';
4
4
  export * from './table';
5
5
  export * from './map-deque';
6
+ export * from './tokenizer';
@@ -2,20 +2,20 @@ import type { Table, DbOptions, DocType } from './table.types';
2
2
  export declare const DEFAULT_STEP_SIZE = 100;
3
3
  /**
4
4
  * Convert a dot-separated path to a JSON path
5
- * @param dot The dot-separated path
6
- * @returns The JSON path
5
+ * @param dot The dot-separated path string
6
+ * @returns The JSON path string
7
7
  */
8
8
  export declare function toJsonPath(dot: string): string;
9
9
  /**
10
10
  * Get a nested value from an object using a dot-separated path
11
- * @param object The object to get the value from
12
- * @param path The dot-separated path to the value
11
+ * @param object The object to retrieve the value from
12
+ * @param path The dot-separated path string
13
13
  * @returns The value at the specified path, or undefined if not found
14
14
  */
15
15
  export declare function getByPath<T extends object>(object: T, path: string): unknown;
16
16
  /**
17
- * Create a new table in the database with the given options
18
- * @param options The options for creating the table
19
- * @returns The created table
17
+ * Create and initialize a table in the database with the specified options
18
+ * @param options The options for creating the table, including table name, indexes, backend, and key
19
+ * @returns A promise that resolves to the created Table instance
20
20
  */
21
21
  export declare function createTable<Document extends DocType>(options: DbOptions<Document>): Promise<Table<Document>>;
@@ -1,5 +1,6 @@
1
1
  import type { SqlSeachOptions } from '../select-sql';
2
2
  import type { Backend } from './backend';
3
+ import type { FtsTokenizerOptions } from './tokenizer';
3
4
  import type { Where } from './where';
4
5
  export type DocType = {
5
6
  [key: string]: any;
@@ -10,9 +11,16 @@ type Previous = [never, 0, 1, 2, 3, 4, 5];
10
11
  export type DotPath<T, D extends number = 5> = [D] extends [never] ? never : T extends object ? {
11
12
  [K in Extract<keyof T, string>]: T[K] extends object ? K | `${K}.${DotPath<T[K], Previous[D]>}` : K;
12
13
  }[Extract<keyof T, string>] : never;
14
+ export type FtsTokenizer = 'porter' | 'simple' | 'icu' | 'unicode61' | FtsTokenizerOptions;
15
+ export interface FtsType<Document extends DocType> {
16
+ readonly type: 'fts';
17
+ readonly path: DotPath<Document>;
18
+ readonly tokenizer?: FtsTokenizer;
19
+ }
20
+ export type IndexDeclaration<Document extends DocType> = DotPath<Document> | `fts:${DotPath<Document>}` | FtsType<Document>;
13
21
  export interface DbOptions<Document extends DocType> {
14
22
  readonly tableName: string;
15
- readonly indexes?: Array<DotPath<Document>>;
23
+ readonly indexes?: Array<IndexDeclaration<Document>>;
16
24
  readonly backend: Backend;
17
25
  readonly key?: DotPath<Document>;
18
26
  readonly disablePragmaOptimization?: boolean;
@@ -0,0 +1,11 @@
1
+ export interface FtsTokenizerOptions {
2
+ readonly removeDiacritics?: 0 | 1 | 2;
3
+ readonly tokenChars?: string;
4
+ readonly separators?: string;
5
+ }
6
+ /**
7
+ * Create a custom FTS5 tokenizer string based on the provided options
8
+ * @param options Options to customize the tokenizer
9
+ * @returns A string representing the FTS5 tokenizer configuration
10
+ */
11
+ export declare function unicodeTokenizer(options?: FtsTokenizerOptions): string;
@@ -8,6 +8,7 @@ interface Condition<T> {
8
8
  readonly in?: T[];
9
9
  readonly notIn?: T[];
10
10
  readonly like?: T | T[];
11
+ readonly fts?: string | string[];
11
12
  }
12
13
  export type Where<T extends Record<string, unknown>> = {
13
14
  [K in keyof T]?: T[K] extends Record<string, unknown> ? Where<T[K]> : Condition<T[K]> | T[K] | T[K][];
@@ -20,13 +21,15 @@ export type Where<T extends Record<string, unknown>> = {
20
21
  * Write SQL WHERE clause from a Where object
21
22
  * @param where The Where object defining the conditions
22
23
  * @param tableAlias Optional table alias to prefix field names
24
+ * @param tableName Optional table name (required for FTS conditions)
23
25
  * @returns The SQL WHERE clause string (without the "WHERE" keyword)
24
26
  */
25
- export declare function getWhere<T extends Record<string, unknown>>(where: Where<T>, tableAlias?: string): string;
27
+ export declare function getWhere<T extends Record<string, unknown>>(where: Where<T>, tableAlias?: string, tableName?: string): string;
26
28
  /**
27
29
  * Get SQL WHERE clause from a Where object
28
30
  * @param where The Where object defining the conditions
31
+ * @param tableName Optional table name (required for FTS conditions)
29
32
  * @returns The SQL WHERE clause string (without the "WHERE" keyword)
30
33
  */
31
- export declare function getWhereQuery<T extends Record<string, unknown>>(where?: Where<T>): string;
34
+ export declare function getWhereQuery<T extends Record<string, unknown>>(where?: Where<T>, tableName?: string): string;
32
35
  export {};