muya 2.3.3 → 2.4.0

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)}),it("should test fts index with nested fields",async()=>{const e=await c({backend:i,tableName:"TestTableFTSNested",key:"id",indexes:["fts:info.content"]});await e.set({id:"1",info:{content:"The quick brown fox"}}),await e.set({id:"2",info:{content:"jumps over the lazy dog"}}),await e.set({id:"3",info:{content:"hello world"}});const o=[];for await(const n of e.search({where:{info:{content:{fts:["quick","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:{info:{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 _}from"./tokenizer";import{getWhereQuery as h}from"./where";const D=500,C=100;function R(l){return"$."+l}function M(l,a){if(!(!l||!a))return a.split(".").reduce((t,f)=>{if(typeof t=="object"&&t!==null&&f in t)return t[f]},l)}async function z(l){const{backend:a,tableName:t,indexes:f,key:$,disablePragmaOptimization:I}=l,d=$!==void 0;I||(await a.execute("PRAGMA journal_mode=WAL;"),await a.execute("PRAGMA synchronous=NORMAL;"),await a.execute("PRAGMA temp_store=MEMORY;"),await a.execute("PRAGMA cache_size=-20000;")),d?await a.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 a.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=[],y={};for(const e of f??[])if(typeof e=="string"&&e.startsWith("fts:")){const n=e.slice(4),r=n.replaceAll(".","_");T.push(n),y[n]=r}else if(typeof e=="object"&&e.type==="fts"){const n=e.path,r=n.replaceAll(".","_");if(T.push(n),y[n]=r,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 n=String(e);await a.execute(`CREATE INDEX IF NOT EXISTS idx_${t}_${n.replaceAll(/\W/g,"_")}
11
+ ON ${t} (json_extract(data, '${R(n)}'));`)}if(T.length>0){let e;typeof E=="object"?e=_(E):E===void 0?e='"unicode61", "remove_diacritics=1"':e=E;const n=T.map(o=>y[o]).join(", "),r=`
12
+ CREATE VIRTUAL TABLE IF NOT EXISTS ${t}_fts
13
+ USING fts5(${n}, tokenize=${e});
14
+ `;await a.execute(r),await a.execute(`
15
+ CREATE TRIGGER IF NOT EXISTS ${t}_ai
16
+ AFTER INSERT ON ${t}
17
+ BEGIN
18
+ INSERT INTO ${t}_fts(rowid, ${n})
19
+ VALUES (
20
+ new.rowid,
21
+ ${T.map(o=>`json_extract(new.data, '${R(o)}')`).join(", ")}
22
+ );
23
+ END;
24
+ `),await a.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 a.execute(`
31
+ CREATE TRIGGER IF NOT EXISTS ${t}_au
32
+ AFTER UPDATE ON ${t}
33
+ BEGIN
34
+ UPDATE ${t}_fts
35
+ SET ${T.map(o=>`${y[o]}=json_extract(new.data, '${R(o)}')`).join(", ")}
36
+ WHERE rowid = old.rowid;
37
+ END;
38
+ `)}function N(e){if(d)return M(e,String($))}async function L(e){return(await e.select("SELECT changes() AS c"))[0]?.c??0}const O={backend:a,async set(e,n){const r=n??a,o=JSON.stringify(e);if(d){const s=N(e);if(s==null)throw new Error(`Document is missing the configured key "${String($)}".`);if(await r.execute(`UPDATE ${t} SET data = ? WHERE key = ?`,[o,s]),await L(r)===1)return{key:s,op:"update"};try{return await r.execute(`INSERT INTO ${t} (key, data) VALUES (?, ?)`,[s,o]),{key:s,op:"insert"}}catch{return await r.execute(`UPDATE ${t} SET data = ? WHERE key = ?`,[o,s]),{key:s,op:"update"}}}await r.execute(`INSERT INTO ${t} (data) VALUES (?)`,[o]);const u=(await r.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,n=r=>r){const r=d?"key = ?":"rowid = ?",o=await a.select(`SELECT rowid, data FROM ${t} WHERE ${r}`,[e]);if(o.length===0)return;const{data:c,rowid:u}=o[0],s=JSON.parse(c);return n(s,{rowId:u})},async delete(e){const n=d?"key = ?":"rowid = ?";if(await a.execute(`DELETE FROM ${t} WHERE ${n}`,[e]),((await a.select("SELECT changes() AS c"))[0]?.c??0)>0)return{key:e,op:"delete"}},async*search(e={}){const{sortBy:n,order:r="asc",limit:o,offset:c=0,where:u,select:s=w=>w,stepSize:i=C}=e,p=h(u,t),A=`SELECT rowid, data FROM ${t} ${p}`;let S=0,k=c;for(;;){let w=A;n?w+=` ORDER BY json_extract(data, '${R(String(n))}') COLLATE NOCASE ${r.toUpperCase()}`:w+=d?` ORDER BY key COLLATE NOCASE ${r.toUpperCase()}`:` ORDER BY rowid ${r.toUpperCase()}`;const g=o?Math.min(i,o-S):i;w+=` LIMIT ${g} OFFSET ${k}`;const m=await a.select(w);if(m.length===0)break;for(const{rowid:x,data:b}of m){if(o&&S>=o)return;const F=JSON.parse(b);yield s(F,{rowId:x}),S++}if(m.length<g||o&&S>=o)break;k+=m.length}},async count(e={}){const n=h(e.where,t),r=`SELECT COUNT(*) as count FROM ${t} ${n}`;return(await a.select(r))[0]?.count??0},async deleteBy(e){const n=h(e,t),r=d?"key":"rowid",o=[];return await a.transaction(async c=>{const u=await c.select(`SELECT ${r} AS k FROM ${t} ${n}`);if(u.length===0)return;const s=u.map(i=>i.k);for(let i=0;i<s.length;i+=D){const p=s.slice(i,i+D),A=p.map(()=>"?").join(",");await c.execute(`DELETE FROM ${t} WHERE ${r} IN (${A})`,p)}for(const i of s)o.push({key:i,op:"delete"})}),o},async clear(){await a.execute(`DELETE FROM ${t}`)},async batchSet(e){const n=[];return await a.transaction(async r=>{for(const o of e){const c=await O.set(o,r);n.push(c)}}),n}};return O}export{C as DEFAULT_STEP_SIZE,z as createTable,M as getByPath,R 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.4.0",
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,177 @@ 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
+ })
320
+
321
+ it('should test fts index with nested fields', async () => {
322
+ const tableFts = await createTable<{ id: string; info: { content: string } }>({
323
+ backend,
324
+ tableName: 'TestTableFTSNested',
325
+ key: 'id',
326
+ indexes: ['fts:info.content'],
327
+ })
328
+ await tableFts.set({ id: '1', info: { content: 'The quick brown fox' } })
329
+ await tableFts.set({ id: '2', info: { content: 'jumps over the lazy dog' } })
330
+ await tableFts.set({ id: '3', info: { content: 'hello world' } })
331
+
332
+ const results: { id: string; info: { content: string } }[] = []
333
+ for await (const doc of tableFts.search({
334
+ where: {
335
+ info: {
336
+ content: { fts: ['quick', 'fox'] },
337
+ },
338
+ },
339
+ })) {
340
+ results.push(doc)
341
+ }
342
+ expect(results.length).toBe(1)
343
+ expect(results[0].id).toBe('1')
344
+
345
+ const results2: { id: string; info: { content: string } }[] = []
346
+ for await (const doc of tableFts.search({ where: { info: { content: { fts: ['the'] } } } })) {
347
+ results2.push(doc)
348
+ }
349
+ expect(results2.length).toBe(2)
350
+ })
181
351
  })
@@ -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,95 @@ export async function createTable<Document extends DocType>(options: DbOptions<D
70
69
  `)
71
70
  }
72
71
 
73
- // JSON expression indexes
72
+ // Track FTS fields and map dot paths to valid SQLite column names
73
+ let ftsTokenizer: string | undefined | FtsTokenizerOptions
74
+ const ftsFields: string[] = []
75
+ const ftsFieldMap: Record<string, string> = {} // dot path -> column name
76
+
74
77
  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, '_')}
78
+ if (typeof index === 'string' && index.startsWith('fts:')) {
79
+ const path = index.slice(4)
80
+ const col = path.replaceAll('.', '_')
81
+ ftsFields.push(path)
82
+ ftsFieldMap[path] = col
83
+ } else if (typeof index === 'object' && index.type === 'fts') {
84
+ const path = index.path
85
+ const col = path.replaceAll('.', '_')
86
+ ftsFields.push(path)
87
+ ftsFieldMap[path] = col
88
+ if (index.tokenizer) {
89
+ if (!ftsTokenizer) {
90
+ ftsTokenizer = index.tokenizer
91
+ } else if (ftsTokenizer !== index.tokenizer) {
92
+ throw new Error(`Conflicting FTS tokenizers: already using "${ftsTokenizer}", got "${index.tokenizer}"`)
93
+ }
94
+ }
95
+ } else {
96
+ const idx = String(index)
97
+ await backend.execute(
98
+ `CREATE INDEX IF NOT EXISTS idx_${tableName}_${idx.replaceAll(/\W/g, '_')}
78
99
  ON ${tableName} (json_extract(data, '${toJsonPath(idx)}'));`,
79
- )
100
+ )
101
+ }
102
+ }
103
+
104
+ // Create FTS table + triggers
105
+ if (ftsFields.length > 0) {
106
+ let tokenizerSpec: string
107
+ if (typeof ftsTokenizer === 'object') {
108
+ tokenizerSpec = unicodeTokenizer(ftsTokenizer)
109
+ } else if (ftsTokenizer === undefined) {
110
+ tokenizerSpec = '"unicode61", "remove_diacritics=1"'
111
+ } else {
112
+ tokenizerSpec = ftsTokenizer
113
+ }
114
+ // Use mapped column names for FTS columns
115
+ const ftsColumns = ftsFields.map((f) => ftsFieldMap[f]).join(', ')
116
+ const query = `
117
+ CREATE VIRTUAL TABLE IF NOT EXISTS ${tableName}_fts
118
+ USING fts5(${ftsColumns}, tokenize=${tokenizerSpec});
119
+ `
120
+
121
+ await backend.execute(query)
122
+
123
+ // Insert trigger
124
+ await backend.execute(`
125
+ CREATE TRIGGER IF NOT EXISTS ${tableName}_ai
126
+ AFTER INSERT ON ${tableName}
127
+ BEGIN
128
+ INSERT INTO ${tableName}_fts(rowid, ${ftsColumns})
129
+ VALUES (
130
+ new.rowid,
131
+ ${ftsFields.map((f) => `json_extract(new.data, '${toJsonPath(f)}')`).join(', ')}
132
+ );
133
+ END;
134
+ `)
135
+
136
+ // Delete trigger
137
+ await backend.execute(`
138
+ CREATE TRIGGER IF NOT EXISTS ${tableName}_ad
139
+ AFTER DELETE ON ${tableName}
140
+ BEGIN
141
+ DELETE FROM ${tableName}_fts WHERE rowid = old.rowid;
142
+ END;
143
+ `)
144
+
145
+ // Update trigger
146
+ await backend.execute(`
147
+ CREATE TRIGGER IF NOT EXISTS ${tableName}_au
148
+ AFTER UPDATE ON ${tableName}
149
+ BEGIN
150
+ UPDATE ${tableName}_fts
151
+ SET ${ftsFields.map((f) => `${ftsFieldMap[f]}=json_extract(new.data, '${toJsonPath(f)}')`).join(', ')}
152
+ WHERE rowid = old.rowid;
153
+ END;
154
+ `)
80
155
  }
81
156
 
82
157
  /**
83
- * Get the key value from a document
158
+ * Get the value of the configured key from a document
84
159
  * @param document The document to extract the key from
85
- * @returns The key value or undefined if no user key is configured
160
+ * @returns The value of the key, or undefined if not found or no key is configured
86
161
  */
87
162
  function getKeyFromDocument(document: Document): Key | undefined {
88
163
  if (!hasUserKey) return undefined
@@ -90,9 +165,9 @@ export async function createTable<Document extends DocType>(options: DbOptions<D
90
165
  }
91
166
 
92
167
  /**
93
- * Get the number of rows changed by the last operation
94
- * @param conn The database connection
95
- * @returns The number of rows changed
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
96
171
  */
97
172
  async function getChanges(conn: typeof backend): Promise<number> {
98
173
  const r = await conn.select<Array<{ c: number }>>(`SELECT changes() AS c`)
@@ -109,17 +184,13 @@ export async function createTable<Document extends DocType>(options: DbOptions<D
109
184
  if (hasUserKey) {
110
185
  const id = getKeyFromDocument(document)
111
186
  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
- )
187
+ throw new Error(`Document is missing the configured key "${String(key)}".`)
115
188
  }
116
189
 
117
- // Fast path: UPDATE first
118
190
  await db.execute(`UPDATE ${tableName} SET data = ? WHERE key = ?`, [json, id])
119
191
  const updated = await getChanges(db)
120
192
  if (updated === 1) return { key: id, op: 'update' }
121
193
 
122
- // No row updated => try INSERT
123
194
  try {
124
195
  await db.execute(`INSERT INTO ${tableName} (key, data) VALUES (?, ?)`, [id, json])
125
196
  return { key: id, op: 'insert' }
@@ -129,7 +200,6 @@ export async function createTable<Document extends DocType>(options: DbOptions<D
129
200
  }
130
201
  }
131
202
 
132
- // ROWID mode
133
203
  await db.execute(`INSERT INTO ${tableName} (data) VALUES (?)`, [json])
134
204
  const rows = await db.select<Array<{ id: number }>>(`SELECT last_insert_rowid() AS id`)
135
205
  const rowid = rows[0]?.id
@@ -139,7 +209,6 @@ export async function createTable<Document extends DocType>(options: DbOptions<D
139
209
 
140
210
  async get<Selected = Document>(
141
211
  keyValue: Key,
142
- // keep meta available and consistent with search() { rowId }
143
212
  selector: (document: Document, meta: { rowId: number }) => Selected = (d) => d as unknown as Selected,
144
213
  ) {
145
214
  const whereKey = hasUserKey ? `key = ?` : `rowid = ?`
@@ -172,7 +241,7 @@ export async function createTable<Document extends DocType>(options: DbOptions<D
172
241
  stepSize = DEFAULT_STEP_SIZE,
173
242
  } = options
174
243
 
175
- const whereSql = getWhereQuery<Document>(where)
244
+ const whereSql = getWhereQuery<Document>(where, tableName)
176
245
  const baseQuery = `SELECT rowid, data FROM ${tableName} ${whereSql}`
177
246
 
178
247
  let yielded = 0
@@ -205,14 +274,14 @@ export async function createTable<Document extends DocType>(options: DbOptions<D
205
274
  },
206
275
 
207
276
  async count(options: { where?: Where<Document> } = {}) {
208
- const whereSql = getWhereQuery<Document>(options.where)
277
+ const whereSql = getWhereQuery<Document>(options.where, tableName)
209
278
  const query = `SELECT COUNT(*) as count FROM ${tableName} ${whereSql}`
210
279
  const result = await backend.select<Array<{ count: number }>>(query)
211
280
  return result[0]?.count ?? 0
212
281
  },
213
282
 
214
283
  async deleteBy(where: Where<Document>) {
215
- const whereSql = getWhereQuery<Document>(where)
284
+ const whereSql = getWhereQuery<Document>(where, tableName)
216
285
  const keyCol = hasUserKey ? 'key' : 'rowid'
217
286
  const results: MutationResult[] = []
218
287
 
@@ -236,6 +305,7 @@ export async function createTable<Document extends DocType>(options: DbOptions<D
236
305
  async clear() {
237
306
  await backend.execute(`DELETE FROM ${tableName}`)
238
307
  },
308
+
239
309
  async batchSet(documents: Document[]) {
240
310
  const mutations: MutationResult[] = []
241
311
  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 {};