local-lite 0.0.4 → 0.0.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.
@@ -17,6 +17,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
17
17
  exports.SyncIndicator = exports.SyncInspector = void 0;
18
18
  exports.clearLiveQueryCache = clearLiveQueryCache;
19
19
  exports.useLiveQuery = useLiveQuery;
20
+ exports.useLiveQueries = useLiveQueries;
20
21
  exports.useLiveRow = useLiveRow;
21
22
  exports.useLiveInfiniteQuery = useLiveInfiniteQuery;
22
23
  exports.useMutations = useMutations;
@@ -32,10 +33,14 @@ function areRowsEqual(a, b) {
32
33
  if (aObj.id !== undefined && bObj.id !== undefined && aObj.id !== bObj.id) {
33
34
  return false;
34
35
  }
35
- if (aObj.updated_at !== undefined &&
36
- bObj.updated_at !== undefined &&
37
- aObj.updated_at !== bObj.updated_at) {
38
- return false;
36
+ // Fast path: if both records have an identical updated_at timestamp, they are equal.
37
+ // This bypasses expensive Object.keys loops and JSON.stringify on large metadata columns.
38
+ if (aObj.id !== undefined &&
39
+ bObj.id !== undefined &&
40
+ aObj.id === bObj.id &&
41
+ aObj.updated_at !== undefined &&
42
+ bObj.updated_at !== undefined) {
43
+ return aObj.updated_at === bObj.updated_at;
39
44
  }
40
45
  const aKeys = Object.keys(aObj);
41
46
  const bKeys = Object.keys(bObj);
@@ -171,6 +176,186 @@ function useLiveQuery(client, tableName, queryOrOptions) {
171
176
  }, [client, tableName, queryKey, refetch, scheduleRefetch]);
172
177
  return { data, refetch, loading };
173
178
  }
179
+ function normalizeMultiQueries(input) {
180
+ const items = [];
181
+ if (Array.isArray(input)) {
182
+ for (const item of input) {
183
+ if (typeof item === 'string') {
184
+ items.push({
185
+ key: item,
186
+ table: item,
187
+ queryKey: 'all',
188
+ cacheKey: `${item}:all`,
189
+ });
190
+ }
191
+ else if (typeof item === 'object' && item !== null && item.table) {
192
+ const { table, ...options } = item;
193
+ const qKey = JSON.stringify(options);
194
+ items.push({
195
+ key: table,
196
+ table,
197
+ queryKey: qKey,
198
+ cacheKey: `${table}:${qKey}`,
199
+ options,
200
+ });
201
+ }
202
+ }
203
+ }
204
+ else if (typeof input === 'object' && input !== null) {
205
+ for (const [key, spec] of Object.entries(input)) {
206
+ if (typeof spec === 'string') {
207
+ items.push({
208
+ key,
209
+ table: spec,
210
+ queryKey: 'all',
211
+ cacheKey: `${spec}:all`,
212
+ });
213
+ }
214
+ else if (typeof spec === 'function') {
215
+ items.push({
216
+ key,
217
+ table: key,
218
+ queryKey: 'custom',
219
+ cacheKey: `${key}:custom`,
220
+ fn: spec,
221
+ });
222
+ }
223
+ else if (typeof spec === 'object' && spec !== null) {
224
+ const table = spec.table || key;
225
+ const options = { ...spec };
226
+ delete options.table;
227
+ const qKey = JSON.stringify(options);
228
+ items.push({
229
+ key,
230
+ table,
231
+ queryKey: qKey,
232
+ cacheKey: `${table}:${qKey}`,
233
+ options,
234
+ });
235
+ }
236
+ }
237
+ }
238
+ return items;
239
+ }
240
+ /**
241
+ * Reactive multi-table live query hook that loads data from multiple tables concurrently.
242
+ *
243
+ * Benefits:
244
+ * - Parallel fetching: queries SQLite tables concurrently via Promise.all.
245
+ * - Single atomic React commit: coalesces multiple table results into a single state update,
246
+ * preventing cascading re-renders and UI freezing.
247
+ * - Multi-table event coordination: listens to mutations across all requested tables,
248
+ * coalescing updates so downstream calculations only run once.
249
+ * - Zero-latency cache read-through: returns instant cached rows on mount.
250
+ *
251
+ * Usage with dictionary:
252
+ * ```ts
253
+ * const { data, loading } = useLiveQueries(client, {
254
+ * projects: 'projects',
255
+ * tasks: { where: { project_id: '123' }, orderBy: 'created_at' },
256
+ * tags: { table: 'labels' }
257
+ * });
258
+ * // data.projects, data.tasks, data.tags
259
+ * ```
260
+ *
261
+ * Usage with array:
262
+ * ```ts
263
+ * const { data, loading } = useLiveQueries(client, ['projects', 'tasks', 'tags']);
264
+ * // data.projects, data.tasks, data.tags
265
+ * ```
266
+ */
267
+ function useLiveQueries(client, queries) {
268
+ const signature = (0, react_1.useMemo)(() => {
269
+ return JSON.stringify(queries, (_, val) => (typeof val === 'function' ? 'fn' : val));
270
+ }, [queries]);
271
+ const items = (0, react_1.useMemo)(() => normalizeMultiQueries(queries), [signature]);
272
+ const [data, setData] = (0, react_1.useState)(() => {
273
+ const initial = {};
274
+ for (const item of items) {
275
+ const cached = queryCache.get(item.cacheKey);
276
+ initial[item.key] = cached ? cached : [];
277
+ }
278
+ return initial;
279
+ });
280
+ const [loading, setLoading] = (0, react_1.useState)(() => {
281
+ if (items.length === 0)
282
+ return false;
283
+ for (const item of items) {
284
+ if (!queryCache.has(item.cacheKey))
285
+ return true;
286
+ }
287
+ return false;
288
+ });
289
+ const refetch = (0, react_1.useCallback)(async () => {
290
+ if (!client || !client.db || items.length === 0)
291
+ return;
292
+ try {
293
+ const results = await Promise.all(items.map(async (item) => {
294
+ let inFlight = inFlightQueries.get(item.cacheKey);
295
+ if (!inFlight) {
296
+ inFlight = (async () => {
297
+ if (item.fn) {
298
+ return (await item.fn(client)) || [];
299
+ }
300
+ else if (item.options) {
301
+ return await client.findMany(item.table, item.options);
302
+ }
303
+ else {
304
+ return await client.findMany(item.table);
305
+ }
306
+ })();
307
+ inFlightQueries.set(item.cacheKey, inFlight);
308
+ inFlight.finally(() => {
309
+ inFlightQueries.delete(item.cacheKey);
310
+ });
311
+ }
312
+ try {
313
+ const rows = (await inFlight) || [];
314
+ queryCache.set(item.cacheKey, rows);
315
+ return { key: item.key, rows };
316
+ }
317
+ catch (err) {
318
+ const msg = String(err?.message || err || '');
319
+ if (msg.includes('no such column') || msg.includes('no such table')) {
320
+ return { key: item.key, rows: [] };
321
+ }
322
+ console.error(`Error in useLiveQueries("${item.table}"):`, err);
323
+ return { key: item.key, rows: [] };
324
+ }
325
+ }));
326
+ setData((prev) => {
327
+ let changed = false;
328
+ const next = { ...prev };
329
+ for (const res of results) {
330
+ if (!areRowArraysEqual(prev[res.key], res.rows)) {
331
+ next[res.key] = res.rows;
332
+ changed = true;
333
+ }
334
+ }
335
+ return changed ? next : prev;
336
+ });
337
+ }
338
+ finally {
339
+ setLoading(false);
340
+ }
341
+ }, [client, items]);
342
+ const scheduleRefetch = (0, runner_js_1.useCoalescedRunner)(refetch);
343
+ (0, react_1.useEffect)(() => {
344
+ if (!client || !client.db || items.length === 0)
345
+ return;
346
+ refetch();
347
+ const tables = Array.from(new Set(items.map((i) => i.table).filter(Boolean)));
348
+ const unsubs = tables.map((tbl) => client.events.on(tbl, () => {
349
+ scheduleRefetch();
350
+ }));
351
+ return () => {
352
+ for (const unsub of unsubs) {
353
+ unsub();
354
+ }
355
+ };
356
+ }, [client, signature, refetch, scheduleRefetch]);
357
+ return { data, loading, refetch };
358
+ }
174
359
  /**
175
360
  * Reactive hook to query a single record by primary key with 0ms cache read-through.
176
361
  * Automatically re-renders only when this specific row or table changes.
@@ -0,0 +1,49 @@
1
+ export interface SchemaIndexDefinition {
2
+ table: string;
3
+ columns: string[];
4
+ isOneToOne: boolean;
5
+ sql: string;
6
+ referencedRelation?: string;
7
+ referencedColumns?: string[];
8
+ comment?: string;
9
+ }
10
+ export interface GenerateSchemaIndexesOptions {
11
+ /**
12
+ * Path to the Supabase database.types.ts file.
13
+ */
14
+ typesPath?: string;
15
+ /**
16
+ * Raw string content of the database.types.ts file.
17
+ */
18
+ typesContent?: string;
19
+ /**
20
+ * Target path where the generated TypeScript file should be written.
21
+ */
22
+ outputPath?: string;
23
+ /**
24
+ * Whether to also generate composite sync indexes for tables with business_id and deleted.
25
+ * Default: true
26
+ */
27
+ includeBusinessSyncIndexes?: boolean;
28
+ }
29
+ export interface GenerateSchemaIndexesResult {
30
+ indexes: SchemaIndexDefinition[];
31
+ code: string;
32
+ written: boolean;
33
+ outputPath?: string;
34
+ }
35
+ /**
36
+ * Parses raw Supabase database.types.ts content and extracts foreign-key index definitions.
37
+ */
38
+ export declare function parseSchemaIndexes(content: string, options?: {
39
+ includeBusinessSyncIndexes?: boolean;
40
+ }): SchemaIndexDefinition[];
41
+ /**
42
+ * Generates TypeScript source code containing index definitions and an applicator helper.
43
+ */
44
+ export declare function formatSchemaIndexesCode(indexes: SchemaIndexDefinition[]): string;
45
+ /**
46
+ * Core generator function.
47
+ * Accepts the path or content of database.types.ts and generates schema indexes.
48
+ */
49
+ export declare function generateSchemaIndexes(typesPathOrOptions: string | GenerateSchemaIndexesOptions, maybeOutputPath?: string): Promise<GenerateSchemaIndexesResult>;
@@ -0,0 +1,209 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseSchemaIndexes = parseSchemaIndexes;
4
+ exports.formatSchemaIndexesCode = formatSchemaIndexesCode;
5
+ exports.generateSchemaIndexes = generateSchemaIndexes;
6
+ async function getNodeModules() {
7
+ try {
8
+ const fsModName = 'node:fs';
9
+ const pathModName = 'node:path';
10
+ const fsMod = await import(/* @vite-ignore */ fsModName);
11
+ const pathMod = await import(/* @vite-ignore */ pathModName);
12
+ return {
13
+ fs: (fsMod.default || fsMod),
14
+ path: (pathMod.default || pathMod),
15
+ };
16
+ }
17
+ catch {
18
+ const req = typeof require !== 'undefined' ? require : null;
19
+ if (req) {
20
+ return {
21
+ fs: req('fs'),
22
+ path: req('path'),
23
+ };
24
+ }
25
+ throw new Error('Node.js filesystem (fs) and path modules are not available in this environment.');
26
+ }
27
+ }
28
+ /**
29
+ * Parses raw Supabase database.types.ts content and extracts foreign-key index definitions.
30
+ */
31
+ function parseSchemaIndexes(content, options = {}) {
32
+ const lines = content.split(/\r?\n/);
33
+ const includeBusiness = options.includeBusinessSyncIndexes ?? true;
34
+ let currentTable = null;
35
+ let inRow = false;
36
+ let inRelationships = false;
37
+ let relLines = [];
38
+ const tableColumns = new Map();
39
+ const relationships = [];
40
+ for (const line of lines) {
41
+ // Detect table definition: " <table_name>: {"
42
+ const tableMatch = line.match(/^\s{6}([a-z0-9_]+):\s*\{/);
43
+ if (tableMatch && !['Row', 'Insert', 'Update'].includes(tableMatch[1])) {
44
+ currentTable = tableMatch[1];
45
+ if (!tableColumns.has(currentTable)) {
46
+ tableColumns.set(currentTable, new Set());
47
+ }
48
+ }
49
+ // Track column names in Row: { ... }
50
+ if (line.includes('Row: {')) {
51
+ inRow = true;
52
+ continue;
53
+ }
54
+ if (inRow) {
55
+ if (line.trim() === '};' || line.trim() === '}') {
56
+ inRow = false;
57
+ }
58
+ else if (currentTable) {
59
+ const colMatch = line.match(/^\s{10}([a-z0-9_]+):/);
60
+ if (colMatch) {
61
+ tableColumns.get(currentTable)?.add(colMatch[1]);
62
+ }
63
+ }
64
+ }
65
+ // Skip empty relationships
66
+ if (line.includes('Relationships: [];') || line.includes('Relationships: []')) {
67
+ continue;
68
+ }
69
+ if (line.includes('Relationships: [')) {
70
+ inRelationships = true;
71
+ relLines = [];
72
+ continue;
73
+ }
74
+ if (inRelationships) {
75
+ if (line.trim() === '];' || line.trim() === ']') {
76
+ inRelationships = false;
77
+ if (currentTable) {
78
+ const block = relLines.join('\n');
79
+ const itemRegex = /foreignKeyName:\s*['"]([^'"]+)['"][\s\S]*?columns:\s*\[([^\]]+)\][\s\S]*?isOneToOne:\s*(true|false)[\s\S]*?referencedRelation:\s*['"]([^'"]+)['"](?:[\s\S]*?referencedColumns:\s*\[([^\]]+)\])?/g;
80
+ const matches = [...block.matchAll(itemRegex)];
81
+ for (const m of matches) {
82
+ const columns = m[2].split(',').map(c => c.trim().replace(/['"]/g, ''));
83
+ const isOneToOne = m[3] === 'true';
84
+ const referencedRelation = m[4];
85
+ const referencedColumns = m[5]
86
+ ? m[5].split(',').map(c => c.trim().replace(/['"]/g, ''))
87
+ : undefined;
88
+ const isUnique = isOneToOne ? 'UNIQUE ' : '';
89
+ const colSql = columns.map(c => `"${c}"`).join(', ');
90
+ const colSlug = columns.join('_');
91
+ const indexName = `idx_${currentTable}_${colSlug}`;
92
+ relationships.push({
93
+ table: currentTable,
94
+ columns,
95
+ isOneToOne,
96
+ referencedRelation,
97
+ referencedColumns,
98
+ sql: `CREATE ${isUnique}INDEX IF NOT EXISTS "${indexName}" ON "${currentTable}" (${colSql});`,
99
+ comment: referencedRelation
100
+ ? `References ${referencedRelation}${referencedColumns ? `(${referencedColumns.join(', ')})` : ''}`
101
+ : undefined,
102
+ });
103
+ }
104
+ }
105
+ continue;
106
+ }
107
+ relLines.push(line);
108
+ }
109
+ }
110
+ // Optionally append composite business sync indexes
111
+ if (includeBusiness) {
112
+ for (const [table, cols] of tableColumns.entries()) {
113
+ if (cols.has('business_id') && cols.has('deleted')) {
114
+ const idxName = `idx_${table}_biz_del`;
115
+ if (!relationships.some(r => r.table === table &&
116
+ r.columns.length === 2 &&
117
+ r.columns.includes('business_id') &&
118
+ r.columns.includes('deleted'))) {
119
+ relationships.push({
120
+ table,
121
+ columns: ['business_id', 'deleted'],
122
+ isOneToOne: false,
123
+ sql: `CREATE INDEX IF NOT EXISTS "${idxName}" ON "${table}" ("business_id", "deleted");`,
124
+ comment: 'Composite business partition index for zero-latency local queries',
125
+ });
126
+ }
127
+ }
128
+ }
129
+ }
130
+ return relationships;
131
+ }
132
+ /**
133
+ * Generates TypeScript source code containing index definitions and an applicator helper.
134
+ */
135
+ function formatSchemaIndexesCode(indexes) {
136
+ const sqlStatements = indexes.map(i => i.sql).join('\n');
137
+ return `/**
138
+ * AUTO-GENERATED FILE - DO NOT EDIT MANUALLY.
139
+ * Generated by local-lite schema index generator.
140
+ */
141
+
142
+ /**
143
+ * SQLite index statements extracted from Supabase database schema.
144
+ * Total indexes: ${indexes.length}
145
+ */
146
+ export const SCHEMA_INDEXES: readonly string[] = \`
147
+ ${sqlStatements}
148
+ \`.trim().split('\\n');
149
+
150
+ /**
151
+ * Applies all schema indexes into SQLite to ensure zero-latency local reads.
152
+ * Executes each statement safely, ignoring errors if tables are not yet created.
153
+ */
154
+ export async function ensureSchemaIndexes(db: {
155
+ execute: (query: string, params?: any[]) => Promise<any>;
156
+ }): Promise<void> {
157
+ for (const sql of SCHEMA_INDEXES) {
158
+ try {
159
+ await db.execute(sql);
160
+ } catch {
161
+ // Safe fallback if table has not been created yet
162
+ }
163
+ }
164
+ }
165
+ `;
166
+ }
167
+ /**
168
+ * Core generator function.
169
+ * Accepts the path or content of database.types.ts and generates schema indexes.
170
+ */
171
+ async function generateSchemaIndexes(typesPathOrOptions, maybeOutputPath) {
172
+ const options = typeof typesPathOrOptions === 'string'
173
+ ? { typesPath: typesPathOrOptions, outputPath: maybeOutputPath }
174
+ : typesPathOrOptions;
175
+ let content = options.typesContent;
176
+ if (!content && options.typesPath) {
177
+ const { fs, path } = await getNodeModules();
178
+ const resolvedPath = path.resolve(options.typesPath);
179
+ if (!fs.existsSync(resolvedPath)) {
180
+ throw new Error(`Types file not found at: ${resolvedPath}`);
181
+ }
182
+ content = fs.readFileSync(resolvedPath, 'utf8');
183
+ }
184
+ if (!content) {
185
+ throw new Error('Either typesPath or typesContent must be provided to generateSchemaIndexes.');
186
+ }
187
+ const indexes = parseSchemaIndexes(content, {
188
+ includeBusinessSyncIndexes: options.includeBusinessSyncIndexes,
189
+ });
190
+ const code = formatSchemaIndexesCode(indexes);
191
+ let written = false;
192
+ let resolvedOutputPath;
193
+ if (options.outputPath) {
194
+ const { fs, path } = await getNodeModules();
195
+ resolvedOutputPath = path.resolve(options.outputPath);
196
+ const parentDir = path.dirname(resolvedOutputPath);
197
+ if (!fs.existsSync(parentDir)) {
198
+ fs.mkdirSync(parentDir, { recursive: true });
199
+ }
200
+ fs.writeFileSync(resolvedOutputPath, code, 'utf8');
201
+ written = true;
202
+ }
203
+ return {
204
+ indexes,
205
+ code,
206
+ written,
207
+ outputPath: resolvedOutputPath,
208
+ };
209
+ }
@@ -0,0 +1 @@
1
+ export * from './generator.js';
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./generator.js"), exports);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "local-lite",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
4
4
  "description": "Never let a lost connection break a promise. Local-first sync that faithfully keeps data safe until you're back online.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -26,6 +26,11 @@
26
26
  "import": "./dist/config/index.js",
27
27
  "default": "./dist/config/index.js"
28
28
  },
29
+ "./schema": {
30
+ "types": "./dist/schema/index.d.ts",
31
+ "import": "./dist/schema/index.js",
32
+ "default": "./dist/schema/index.js"
33
+ },
29
34
  "./supabase": {
30
35
  "types": "./dist/adapters/remote/supabase.d.ts",
31
36
  "import": "./dist/adapters/remote/supabase.js",
@@ -127,7 +132,7 @@
127
132
  "@supabase/supabase-js": "^2.118.0",
128
133
  "@types/react": "^19.3.0",
129
134
  "@types/uuid": "^11.0.0",
130
- "expo-sqlite": "^57.0.4",
135
+ "expo-sqlite": "^58.0.13",
131
136
  "react": "^19.3.1",
132
137
  "typescript": "^7.0.2",
133
138
  "vitest": "^5.0.5"