@promptbook/cli 0.112.0-81 → 0.112.0-84
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/agents-server/README.md +7 -1
- package/apps/agents-server/src/database/$provideSupabaseForServer.ts +6 -0
- package/apps/agents-server/src/database/agentsServerDatabaseMode.ts +34 -0
- package/apps/agents-server/src/database/sqlite/$provideLocalSqliteSupabase.ts +1445 -0
- package/apps/agents-server/src/database/sqlite/resolveAgentsServerSqliteDatabasePath.ts +13 -0
- package/apps/agents-server/src/tools/$provideServer.ts +29 -2
- package/apps/agents-server/src/utils/serverRegistry.ts +13 -0
- package/apps/agents-server/src/utils/userChat/finalizeUserChatJob.ts +42 -0
- package/apps/agents-server/src/utils/userChatTimeout/userChatTimeoutStore/claimNextDueUserChatTimeout.ts +63 -0
- package/apps/agents-server/src/utils/userChatTimeout/userChatTimeoutStore/recoverExpiredRunningUserChatTimeouts.ts +47 -0
- package/apps/agents-server/src/utils/validateApiKey.ts +2 -18
- package/esm/apps/agents-server/src/database/agentsServerDatabaseMode.d.ts +20 -0
- package/esm/index.es.js +120 -5
- package/esm/index.es.js.map +1 -1
- package/esm/src/version.d.ts +1 -1
- package/package.json +2 -1
- package/src/book-components/Chat/save/pdf/buildChatPdf.ts +3 -26
- package/src/cli/cli-commands/agents-server/ensureAgentsServerEnvFile.ts +3 -1
- package/src/cli/cli-commands/agents-server/startAgentsServer.ts +148 -3
- package/src/other/templates/getTemplatesPipelineCollection.ts +844 -694
- package/src/version.ts +2 -2
- package/src/versions.txt +2 -0
- package/umd/apps/agents-server/src/database/agentsServerDatabaseMode.d.ts +20 -0
- package/umd/index.umd.js +120 -5
- package/umd/index.umd.js.map +1 -1
- package/umd/src/version.d.ts +1 -1
|
@@ -0,0 +1,1445 @@
|
|
|
1
|
+
import { existsSync, mkdirSync } from 'fs';
|
|
2
|
+
import { dirname } from 'path';
|
|
3
|
+
import type { SupabaseClient } from '@supabase/supabase-js';
|
|
4
|
+
import type { TODO_any } from '@promptbook-local/types';
|
|
5
|
+
import { resolveAgentsServerSqliteDatabasePath } from './resolveAgentsServerSqliteDatabasePath';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Minimal query result shape consumed by Agents Server Supabase call sites.
|
|
9
|
+
*/
|
|
10
|
+
type LocalSqliteQueryResult<TData = TODO_any> = {
|
|
11
|
+
readonly data: TData | null;
|
|
12
|
+
readonly error: LocalSqliteError | null;
|
|
13
|
+
readonly count?: number | null;
|
|
14
|
+
readonly status?: number;
|
|
15
|
+
readonly statusText?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Supabase-like error shape returned by the local SQLite adapter.
|
|
20
|
+
*/
|
|
21
|
+
type LocalSqliteError = {
|
|
22
|
+
readonly code?: string;
|
|
23
|
+
readonly message: string;
|
|
24
|
+
readonly details?: string;
|
|
25
|
+
readonly hint?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Supported query operation kinds.
|
|
30
|
+
*/
|
|
31
|
+
type LocalSqliteOperation = 'select' | 'insert' | 'update' | 'delete' | 'upsert';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Query filter captured from Supabase-like fluent calls.
|
|
35
|
+
*/
|
|
36
|
+
type LocalSqliteFilter = {
|
|
37
|
+
readonly column: string;
|
|
38
|
+
readonly operator: 'eq' | 'neq' | 'is' | 'not-is' | 'in' | 'lt' | 'lte' | 'gt' | 'gte' | 'like' | 'ilike';
|
|
39
|
+
readonly value: unknown;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Query order captured from Supabase-like fluent calls.
|
|
44
|
+
*/
|
|
45
|
+
type LocalSqliteOrder = {
|
|
46
|
+
readonly column: string;
|
|
47
|
+
readonly ascending: boolean;
|
|
48
|
+
readonly nullsFirst?: boolean;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Select options supported by Supabase and used by this app.
|
|
53
|
+
*/
|
|
54
|
+
type LocalSqliteSelectOptions = {
|
|
55
|
+
readonly count?: 'exact';
|
|
56
|
+
readonly head?: boolean;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Upsert options supported by Supabase and used by this app.
|
|
61
|
+
*/
|
|
62
|
+
type LocalSqliteUpsertOptions = {
|
|
63
|
+
readonly onConflict?: string;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Shape of the `better-sqlite3` module constructor loaded at runtime.
|
|
68
|
+
*/
|
|
69
|
+
type BetterSqliteConstructor = new (path: string) => BetterSqliteDatabase;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Minimal `better-sqlite3` database surface used by this adapter.
|
|
73
|
+
*/
|
|
74
|
+
type BetterSqliteDatabase = {
|
|
75
|
+
readonly pragma: (source: string) => unknown;
|
|
76
|
+
readonly exec: (source: string) => void;
|
|
77
|
+
readonly prepare: (source: string) => BetterSqliteStatement;
|
|
78
|
+
readonly close?: () => void;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Minimal `better-sqlite3` prepared statement surface used by this adapter.
|
|
83
|
+
*/
|
|
84
|
+
type BetterSqliteStatement = {
|
|
85
|
+
readonly all: (...values: ReadonlyArray<unknown>) => Array<Record<string, unknown>>;
|
|
86
|
+
readonly get: (...values: ReadonlyArray<unknown>) => Record<string, unknown> | undefined;
|
|
87
|
+
readonly run: (...values: ReadonlyArray<unknown>) => {
|
|
88
|
+
readonly changes: number;
|
|
89
|
+
readonly lastInsertRowid: number | bigint;
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Columns whose values are persisted as JSON text in local SQLite.
|
|
95
|
+
*/
|
|
96
|
+
const JSON_COLUMNS_BY_TABLE = new Map<string, ReadonlySet<string>>([
|
|
97
|
+
['Agent', new Set(['agentProfile', 'usage', 'preparedModelRequirements', 'preparedExternals'])],
|
|
98
|
+
['AgentHistory', new Set([])],
|
|
99
|
+
['ChatHistory', new Set(['message', 'usage'])],
|
|
100
|
+
['LlmCache', new Set(['value'])],
|
|
101
|
+
['VectorStoreKnowledgeSourceHashes', new Set([])],
|
|
102
|
+
['Image', new Set([])],
|
|
103
|
+
['File', new Set(['securityResult'])],
|
|
104
|
+
['Message', new Set(['sender', 'recipients', 'metadata'])],
|
|
105
|
+
['MessageSendAttempt', new Set(['raw'])],
|
|
106
|
+
['UserChat', new Set(['messages'])],
|
|
107
|
+
['UserChatJob', new Set(['parameters'])],
|
|
108
|
+
['UserChatTimeout', new Set(['parameters'])],
|
|
109
|
+
['UserData', new Set(['value'])],
|
|
110
|
+
['Wallet', new Set(['jsonSchema'])],
|
|
111
|
+
['ShareTargetPayload', new Set(['attachments'])],
|
|
112
|
+
['CalendarConnection', new Set(['scopes'])],
|
|
113
|
+
['CalendarActivity', new Set(['details'])],
|
|
114
|
+
]);
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Boolean columns stored as integers by SQLite and restored as booleans.
|
|
118
|
+
*/
|
|
119
|
+
const BOOLEAN_COLUMNS = new Set([
|
|
120
|
+
'isAdmin',
|
|
121
|
+
'isRevoked',
|
|
122
|
+
'isGlobal',
|
|
123
|
+
'isUserScoped',
|
|
124
|
+
'isSuccessful',
|
|
125
|
+
'isChatFocused',
|
|
126
|
+
]);
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Tables whose primary key is provided as text rather than generated numerically.
|
|
130
|
+
*/
|
|
131
|
+
const TEXT_PRIMARY_KEY_TABLES = new Set([
|
|
132
|
+
'UserChat',
|
|
133
|
+
'UserChatJob',
|
|
134
|
+
'UserChatTimeout',
|
|
135
|
+
'UserPushSubscription',
|
|
136
|
+
'ShareTargetPayload',
|
|
137
|
+
]);
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Unique constraints required by common Supabase upsert and duplicate-detection flows.
|
|
141
|
+
*/
|
|
142
|
+
const UNIQUE_INDEX_COLUMNS_BY_TABLE = new Map<string, ReadonlyArray<ReadonlyArray<string>>>([
|
|
143
|
+
['_Server', [['name'], ['domain']]],
|
|
144
|
+
['Metadata', [['key']]],
|
|
145
|
+
['ServerLimit', [['key']]],
|
|
146
|
+
['Agent', [['permanentId']]],
|
|
147
|
+
['AgentExternals', [['type', 'hash']]],
|
|
148
|
+
['VectorStoreKnowledgeSourceHashes', [['source']]],
|
|
149
|
+
['User', [['username']]],
|
|
150
|
+
['UserChatJob', [['chatId', 'clientMessageId']]],
|
|
151
|
+
['LlmCache', [['hash']]],
|
|
152
|
+
['OpenAiAssistantCache', [['agentHash']]],
|
|
153
|
+
['ApiTokens', [['token']]],
|
|
154
|
+
['GenerationLock', [['lockKey']]],
|
|
155
|
+
['CustomStylesheet', [['scope']]],
|
|
156
|
+
['CustomJavascript', [['scope']]],
|
|
157
|
+
['Wallet', [['userId', 'agentPermanentId', 'service', 'key']]],
|
|
158
|
+
['UserData', [['userId', 'key']]],
|
|
159
|
+
['UserPushSubscription', [['endpoint']]],
|
|
160
|
+
]);
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Known unique conflict columns used when `.upsert` omits `onConflict`.
|
|
164
|
+
*/
|
|
165
|
+
const DEFAULT_UPSERT_CONFLICT_COLUMNS_BY_TABLE = new Map<string, ReadonlyArray<string>>([
|
|
166
|
+
['AgentExternals', ['type', 'hash']],
|
|
167
|
+
['LlmCache', ['hash']],
|
|
168
|
+
['VectorStoreKnowledgeSourceHashes', ['source']],
|
|
169
|
+
['Metadata', ['key']],
|
|
170
|
+
['ServerLimit', ['key']],
|
|
171
|
+
]);
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Cached SQLite database connection.
|
|
175
|
+
*/
|
|
176
|
+
let sqliteDatabase: BetterSqliteDatabase | null = null;
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Cached Supabase-shaped local client.
|
|
180
|
+
*/
|
|
181
|
+
let localSqliteSupabase: SupabaseClient | null = null;
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Provides a Supabase-shaped client backed by a local SQLite database.
|
|
185
|
+
*/
|
|
186
|
+
export function $provideLocalSqliteSupabase(): SupabaseClient {
|
|
187
|
+
if (localSqliteSupabase) {
|
|
188
|
+
return localSqliteSupabase;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
localSqliteSupabase = new LocalSqliteSupabaseClient(getSqliteDatabase()) as unknown as SupabaseClient;
|
|
192
|
+
return localSqliteSupabase;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Closes the cached SQLite connection and resets adapter state for isolated tests.
|
|
197
|
+
*/
|
|
198
|
+
export function $resetLocalSqliteSupabaseForTests(): void {
|
|
199
|
+
sqliteDatabase?.close?.();
|
|
200
|
+
sqliteDatabase = null;
|
|
201
|
+
localSqliteSupabase = null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Opens and initializes the shared local SQLite database.
|
|
206
|
+
*/
|
|
207
|
+
function getSqliteDatabase(): BetterSqliteDatabase {
|
|
208
|
+
if (sqliteDatabase) {
|
|
209
|
+
return sqliteDatabase;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const databasePath = resolveAgentsServerSqliteDatabasePath();
|
|
213
|
+
const databaseDirectory = dirname(databasePath);
|
|
214
|
+
|
|
215
|
+
if (!existsSync(databaseDirectory)) {
|
|
216
|
+
mkdirSync(databaseDirectory, { recursive: true });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
220
|
+
const BetterSqlite = require('better-sqlite3') as BetterSqliteConstructor;
|
|
221
|
+
sqliteDatabase = new BetterSqlite(databasePath);
|
|
222
|
+
sqliteDatabase.pragma('journal_mode = WAL');
|
|
223
|
+
sqliteDatabase.pragma('foreign_keys = ON');
|
|
224
|
+
|
|
225
|
+
return sqliteDatabase;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Supabase-shaped client with only the table query surface used by Agents Server.
|
|
230
|
+
*/
|
|
231
|
+
class LocalSqliteSupabaseClient {
|
|
232
|
+
public constructor(private readonly database: BetterSqliteDatabase) {}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Starts a query for one SQLite table.
|
|
236
|
+
*/
|
|
237
|
+
public from(tableName: string): LocalSqliteTable {
|
|
238
|
+
return new LocalSqliteTable(this.database, tableName);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Supabase-shaped table entry point. Every operation starts a fresh query builder.
|
|
244
|
+
*/
|
|
245
|
+
class LocalSqliteTable {
|
|
246
|
+
public constructor(
|
|
247
|
+
private readonly database: BetterSqliteDatabase,
|
|
248
|
+
private readonly tableName: string,
|
|
249
|
+
) {}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Starts a select query.
|
|
253
|
+
*/
|
|
254
|
+
public select(columns = '*', options?: LocalSqliteSelectOptions): LocalSqliteQueryBuilder {
|
|
255
|
+
return new LocalSqliteQueryBuilder(this.database, this.tableName).select(columns, options);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Starts an insert query.
|
|
260
|
+
*/
|
|
261
|
+
public insert(values: TODO_any): LocalSqliteQueryBuilder {
|
|
262
|
+
return new LocalSqliteQueryBuilder(this.database, this.tableName).insert(values);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Starts an update query.
|
|
267
|
+
*/
|
|
268
|
+
public update(values: Record<string, unknown>): LocalSqliteQueryBuilder {
|
|
269
|
+
return new LocalSqliteQueryBuilder(this.database, this.tableName).update(values);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Starts a delete query.
|
|
274
|
+
*/
|
|
275
|
+
public delete(): LocalSqliteQueryBuilder {
|
|
276
|
+
return new LocalSqliteQueryBuilder(this.database, this.tableName).delete();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Starts an upsert query.
|
|
281
|
+
*/
|
|
282
|
+
public upsert(values: TODO_any, options?: LocalSqliteUpsertOptions): LocalSqliteQueryBuilder {
|
|
283
|
+
return new LocalSqliteQueryBuilder(this.database, this.tableName).upsert(values, options);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Supabase-shaped thenable query builder executed by `await`.
|
|
289
|
+
*/
|
|
290
|
+
class LocalSqliteQueryBuilder implements PromiseLike<LocalSqliteQueryResult> {
|
|
291
|
+
private operation: LocalSqliteOperation = 'select';
|
|
292
|
+
private selectedColumns = '*';
|
|
293
|
+
private selectOptions: LocalSqliteSelectOptions = {};
|
|
294
|
+
private filters: Array<LocalSqliteFilter> = [];
|
|
295
|
+
private orFilters: Array<string> = [];
|
|
296
|
+
private orders: Array<LocalSqliteOrder> = [];
|
|
297
|
+
private limitCount: number | null = null;
|
|
298
|
+
private offsetCount: number | null = null;
|
|
299
|
+
private singleMode: 'single' | 'maybeSingle' | null = null;
|
|
300
|
+
private mutationRows: Array<Record<string, unknown>> = [];
|
|
301
|
+
private mutationValues: Record<string, unknown> = {};
|
|
302
|
+
private upsertOptions: LocalSqliteUpsertOptions = {};
|
|
303
|
+
private signal: AbortSignal | null = null;
|
|
304
|
+
private isReturningSelection = false;
|
|
305
|
+
|
|
306
|
+
public constructor(
|
|
307
|
+
private readonly database: BetterSqliteDatabase,
|
|
308
|
+
private readonly tableName: string,
|
|
309
|
+
) {}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Configures selected columns or mutation return columns.
|
|
313
|
+
*/
|
|
314
|
+
public select(columns = '*', options: LocalSqliteSelectOptions = {}): this {
|
|
315
|
+
if (this.operation !== 'select') {
|
|
316
|
+
this.isReturningSelection = true;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
this.selectedColumns = columns || '*';
|
|
320
|
+
this.selectOptions = options;
|
|
321
|
+
return this;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Configures inserted rows.
|
|
326
|
+
*/
|
|
327
|
+
public insert(values: TODO_any): this {
|
|
328
|
+
this.operation = 'insert';
|
|
329
|
+
this.mutationRows = normalizeMutationRows(values);
|
|
330
|
+
return this;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Configures updated values.
|
|
335
|
+
*/
|
|
336
|
+
public update(values: Record<string, unknown>): this {
|
|
337
|
+
this.operation = 'update';
|
|
338
|
+
this.mutationValues = stripUndefinedValues(values);
|
|
339
|
+
return this;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Configures row deletion.
|
|
344
|
+
*/
|
|
345
|
+
public delete(): this {
|
|
346
|
+
this.operation = 'delete';
|
|
347
|
+
return this;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Configures inserted-or-updated rows.
|
|
352
|
+
*/
|
|
353
|
+
public upsert(values: TODO_any, options: LocalSqliteUpsertOptions = {}): this {
|
|
354
|
+
this.operation = 'upsert';
|
|
355
|
+
this.mutationRows = normalizeMutationRows(values);
|
|
356
|
+
this.upsertOptions = options;
|
|
357
|
+
return this;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Adds equality filter.
|
|
362
|
+
*/
|
|
363
|
+
public eq(column: string, value: unknown): this {
|
|
364
|
+
this.filters.push({ column, operator: 'eq', value });
|
|
365
|
+
return this;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Adds inequality filter.
|
|
370
|
+
*/
|
|
371
|
+
public neq(column: string, value: unknown): this {
|
|
372
|
+
this.filters.push({ column, operator: 'neq', value });
|
|
373
|
+
return this;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Adds nullability filter.
|
|
378
|
+
*/
|
|
379
|
+
public is(column: string, value: unknown): this {
|
|
380
|
+
this.filters.push({ column, operator: 'is', value });
|
|
381
|
+
return this;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Adds negative filter for supported operators.
|
|
386
|
+
*/
|
|
387
|
+
public not(column: string, operator: string, value: unknown): this {
|
|
388
|
+
if (operator === 'is') {
|
|
389
|
+
this.filters.push({ column, operator: 'not-is', value });
|
|
390
|
+
} else if (operator === 'eq') {
|
|
391
|
+
this.filters.push({ column, operator: 'neq', value });
|
|
392
|
+
}
|
|
393
|
+
return this;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Adds `IN` filter.
|
|
398
|
+
*/
|
|
399
|
+
public in(column: string, value: ReadonlyArray<unknown>): this {
|
|
400
|
+
this.filters.push({ column, operator: 'in', value });
|
|
401
|
+
return this;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Adds less-than filter.
|
|
406
|
+
*/
|
|
407
|
+
public lt(column: string, value: unknown): this {
|
|
408
|
+
this.filters.push({ column, operator: 'lt', value });
|
|
409
|
+
return this;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Adds less-than-or-equal filter.
|
|
414
|
+
*/
|
|
415
|
+
public lte(column: string, value: unknown): this {
|
|
416
|
+
this.filters.push({ column, operator: 'lte', value });
|
|
417
|
+
return this;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Adds greater-than filter.
|
|
422
|
+
*/
|
|
423
|
+
public gt(column: string, value: unknown): this {
|
|
424
|
+
this.filters.push({ column, operator: 'gt', value });
|
|
425
|
+
return this;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Adds greater-than-or-equal filter.
|
|
430
|
+
*/
|
|
431
|
+
public gte(column: string, value: unknown): this {
|
|
432
|
+
this.filters.push({ column, operator: 'gte', value });
|
|
433
|
+
return this;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Adds SQL LIKE filter.
|
|
438
|
+
*/
|
|
439
|
+
public like(column: string, value: string): this {
|
|
440
|
+
this.filters.push({ column, operator: 'like', value });
|
|
441
|
+
return this;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Adds case-insensitive LIKE filter.
|
|
446
|
+
*/
|
|
447
|
+
public ilike(column: string, value: string): this {
|
|
448
|
+
this.filters.push({ column, operator: 'ilike', value });
|
|
449
|
+
return this;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Adds an OR filter in the PostgREST format used by Supabase.
|
|
454
|
+
*/
|
|
455
|
+
public or(filter: string): this {
|
|
456
|
+
this.orFilters.push(filter);
|
|
457
|
+
return this;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Adds ordering.
|
|
462
|
+
*/
|
|
463
|
+
public order(column: string, options: { ascending?: boolean; nullsFirst?: boolean } = {}): this {
|
|
464
|
+
this.orders.push({
|
|
465
|
+
column,
|
|
466
|
+
ascending: options.ascending !== false,
|
|
467
|
+
nullsFirst: options.nullsFirst,
|
|
468
|
+
});
|
|
469
|
+
return this;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Adds a limit.
|
|
474
|
+
*/
|
|
475
|
+
public limit(count: number): this {
|
|
476
|
+
this.limitCount = count;
|
|
477
|
+
return this;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Adds inclusive range pagination.
|
|
482
|
+
*/
|
|
483
|
+
public range(from: number, to: number): this {
|
|
484
|
+
this.offsetCount = from;
|
|
485
|
+
this.limitCount = Math.max(0, to - from + 1);
|
|
486
|
+
return this;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Marks the query as requiring exactly one row.
|
|
491
|
+
*/
|
|
492
|
+
public single(): Promise<LocalSqliteQueryResult> {
|
|
493
|
+
this.singleMode = 'single';
|
|
494
|
+
return this.execute();
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Marks the query as requiring at most one row.
|
|
499
|
+
*/
|
|
500
|
+
public maybeSingle(): Promise<LocalSqliteQueryResult> {
|
|
501
|
+
this.singleMode = 'maybeSingle';
|
|
502
|
+
return this.execute();
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Accepts an abort signal for API compatibility.
|
|
507
|
+
*/
|
|
508
|
+
public abortSignal(signal: AbortSignal): this {
|
|
509
|
+
this.signal = signal;
|
|
510
|
+
return this;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Makes the query builder awaitable.
|
|
515
|
+
*/
|
|
516
|
+
public then<TResult1 = LocalSqliteQueryResult, TResult2 = never>(
|
|
517
|
+
onfulfilled?: ((value: LocalSqliteQueryResult) => TResult1 | PromiseLike<TResult1>) | null,
|
|
518
|
+
onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
|
|
519
|
+
): Promise<TResult1 | TResult2> {
|
|
520
|
+
return this.execute().then(onfulfilled, onrejected);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Executes the configured query.
|
|
525
|
+
*/
|
|
526
|
+
private async execute(): Promise<LocalSqliteQueryResult> {
|
|
527
|
+
try {
|
|
528
|
+
if (this.signal?.aborted) {
|
|
529
|
+
throw new Error('The operation was aborted.');
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
switch (this.operation) {
|
|
533
|
+
case 'insert':
|
|
534
|
+
return this.executeInsert();
|
|
535
|
+
case 'update':
|
|
536
|
+
return this.executeUpdate();
|
|
537
|
+
case 'delete':
|
|
538
|
+
return this.executeDelete();
|
|
539
|
+
case 'upsert':
|
|
540
|
+
return this.executeUpsert();
|
|
541
|
+
case 'select':
|
|
542
|
+
default:
|
|
543
|
+
return this.executeSelect();
|
|
544
|
+
}
|
|
545
|
+
} catch (error) {
|
|
546
|
+
return {
|
|
547
|
+
data: null,
|
|
548
|
+
error: normalizeSqliteError(error),
|
|
549
|
+
status: 400,
|
|
550
|
+
statusText: 'Bad Request',
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Executes a select query.
|
|
557
|
+
*/
|
|
558
|
+
private executeSelect(): LocalSqliteQueryResult {
|
|
559
|
+
const selectedColumns = parseSelectedColumns(this.selectedColumns);
|
|
560
|
+
const requiredColumns = [
|
|
561
|
+
...selectedColumns,
|
|
562
|
+
...this.filters.map((filter) => filter.column),
|
|
563
|
+
...this.orders.map((order) => order.column),
|
|
564
|
+
...this.extractOrFilterColumns(),
|
|
565
|
+
];
|
|
566
|
+
ensureTable(this.database, this.tableName, requiredColumns);
|
|
567
|
+
|
|
568
|
+
const where = this.createWhereClause();
|
|
569
|
+
const orderBy = this.createOrderByClause();
|
|
570
|
+
const limit = this.createLimitClause();
|
|
571
|
+
const count = this.selectOptions.count === 'exact' ? this.executeCount(where) : null;
|
|
572
|
+
|
|
573
|
+
if (this.selectOptions.head) {
|
|
574
|
+
return {
|
|
575
|
+
data: null,
|
|
576
|
+
error: null,
|
|
577
|
+
count,
|
|
578
|
+
status: 200,
|
|
579
|
+
statusText: 'OK',
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const sql = [
|
|
584
|
+
`SELECT ${createSelectExpression(selectedColumns)} FROM ${quoteIdentifier(this.tableName)}`,
|
|
585
|
+
where.sql,
|
|
586
|
+
orderBy,
|
|
587
|
+
limit.sql,
|
|
588
|
+
]
|
|
589
|
+
.filter(Boolean)
|
|
590
|
+
.join(' ');
|
|
591
|
+
const rows = this.database.prepare(sql).all(...where.values, ...limit.values);
|
|
592
|
+
const data = rows.map((row) => deserializeRow(this.tableName, row));
|
|
593
|
+
|
|
594
|
+
return this.finalizeDataResponse(data, count);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Executes an insert query.
|
|
599
|
+
*/
|
|
600
|
+
private executeInsert(): LocalSqliteQueryResult {
|
|
601
|
+
const insertedRowids: Array<number | bigint> = [];
|
|
602
|
+
|
|
603
|
+
for (const rawRow of this.mutationRows) {
|
|
604
|
+
const row = withInsertDefaults(resolveTableBaseName(this.tableName), rawRow);
|
|
605
|
+
ensureTable(this.database, this.tableName, Object.keys(row));
|
|
606
|
+
insertedRowids.push(insertRow(this.database, this.tableName, row).lastInsertRowid);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
return this.createMutationResponse(insertedRowids);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Executes an update query.
|
|
614
|
+
*/
|
|
615
|
+
private executeUpdate(): LocalSqliteQueryResult {
|
|
616
|
+
const updateColumns = Object.keys(this.mutationValues);
|
|
617
|
+
ensureTable(this.database, this.tableName, [
|
|
618
|
+
...updateColumns,
|
|
619
|
+
...this.filters.map((filter) => filter.column),
|
|
620
|
+
...this.extractOrFilterColumns(),
|
|
621
|
+
]);
|
|
622
|
+
|
|
623
|
+
const rowids = this.selectMatchingRowids();
|
|
624
|
+
|
|
625
|
+
if (rowids.length > 0 && updateColumns.length > 0) {
|
|
626
|
+
const assignments = updateColumns.map((column) => `${quoteIdentifier(column)} = ?`).join(', ');
|
|
627
|
+
const values = updateColumns.map((column) => serializeValue(this.tableName, column, this.mutationValues[column]));
|
|
628
|
+
const rowidPlaceholders = rowids.map(() => '?').join(', ');
|
|
629
|
+
this.database
|
|
630
|
+
.prepare(`UPDATE ${quoteIdentifier(this.tableName)} SET ${assignments} WHERE rowid IN (${rowidPlaceholders})`)
|
|
631
|
+
.run(...values, ...rowids);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
return this.createMutationResponse(rowids);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Executes a delete query.
|
|
639
|
+
*/
|
|
640
|
+
private executeDelete(): LocalSqliteQueryResult {
|
|
641
|
+
ensureTable(this.database, this.tableName, [
|
|
642
|
+
...this.filters.map((filter) => filter.column),
|
|
643
|
+
...this.extractOrFilterColumns(),
|
|
644
|
+
]);
|
|
645
|
+
|
|
646
|
+
const rowids = this.selectMatchingRowids();
|
|
647
|
+
if (rowids.length > 0) {
|
|
648
|
+
const rowidPlaceholders = rowids.map(() => '?').join(', ');
|
|
649
|
+
this.database.prepare(`DELETE FROM ${quoteIdentifier(this.tableName)} WHERE rowid IN (${rowidPlaceholders})`).run(...rowids);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
return this.createMutationResponse([]);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Executes an upsert query.
|
|
657
|
+
*/
|
|
658
|
+
private executeUpsert(): LocalSqliteQueryResult {
|
|
659
|
+
const affectedRowids: Array<number | bigint> = [];
|
|
660
|
+
const tableBaseName = resolveTableBaseName(this.tableName);
|
|
661
|
+
const conflictColumns = resolveUpsertConflictColumns(tableBaseName, this.upsertOptions);
|
|
662
|
+
|
|
663
|
+
for (const rawRow of this.mutationRows) {
|
|
664
|
+
const row = withInsertDefaults(tableBaseName, rawRow);
|
|
665
|
+
ensureTable(this.database, this.tableName, [...Object.keys(row), ...conflictColumns]);
|
|
666
|
+
const existingRowid = conflictColumns.length > 0 ? findConflictRowid(this.database, this.tableName, row, conflictColumns) : null;
|
|
667
|
+
|
|
668
|
+
if (existingRowid !== null) {
|
|
669
|
+
updateRowid(this.database, this.tableName, existingRowid, row);
|
|
670
|
+
affectedRowids.push(existingRowid);
|
|
671
|
+
} else {
|
|
672
|
+
affectedRowids.push(insertRow(this.database, this.tableName, row).lastInsertRowid);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
return this.createMutationResponse(affectedRowids);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Creates a mutation response, optionally loading selected mutated rows.
|
|
681
|
+
*/
|
|
682
|
+
private createMutationResponse(rowids: ReadonlyArray<number | bigint>): LocalSqliteQueryResult {
|
|
683
|
+
if (!this.isReturningSelection) {
|
|
684
|
+
return {
|
|
685
|
+
data: null,
|
|
686
|
+
error: null,
|
|
687
|
+
status: 201,
|
|
688
|
+
statusText: 'Created',
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const data = selectRowsByRowids(this.database, this.tableName, rowids, parseSelectedColumns(this.selectedColumns));
|
|
693
|
+
return this.finalizeDataResponse(data, null);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Applies single/maybeSingle response semantics.
|
|
698
|
+
*/
|
|
699
|
+
private finalizeDataResponse(data: Array<Record<string, unknown>>, count: number | null): LocalSqliteQueryResult {
|
|
700
|
+
if (this.singleMode === 'single') {
|
|
701
|
+
if (data.length !== 1) {
|
|
702
|
+
return {
|
|
703
|
+
data: null,
|
|
704
|
+
error: {
|
|
705
|
+
code: 'PGRST116',
|
|
706
|
+
message: `Expected exactly one row, received ${data.length}.`,
|
|
707
|
+
},
|
|
708
|
+
count,
|
|
709
|
+
status: 406,
|
|
710
|
+
statusText: 'Not Acceptable',
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
return { data: data[0], error: null, count, status: 200, statusText: 'OK' };
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
if (this.singleMode === 'maybeSingle') {
|
|
718
|
+
if (data.length > 1) {
|
|
719
|
+
return {
|
|
720
|
+
data: null,
|
|
721
|
+
error: {
|
|
722
|
+
code: 'PGRST116',
|
|
723
|
+
message: `Expected zero or one row, received ${data.length}.`,
|
|
724
|
+
},
|
|
725
|
+
count,
|
|
726
|
+
status: 406,
|
|
727
|
+
statusText: 'Not Acceptable',
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
return { data: data[0] || null, error: null, count, status: 200, statusText: 'OK' };
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
return { data, error: null, count, status: 200, statusText: 'OK' };
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Counts rows matching the current filters.
|
|
739
|
+
*/
|
|
740
|
+
private executeCount(where: { readonly sql: string; readonly values: ReadonlyArray<unknown> }): number {
|
|
741
|
+
const row = this.database
|
|
742
|
+
.prepare(`SELECT COUNT(*) AS "count" FROM ${quoteIdentifier(this.tableName)} ${where.sql}`)
|
|
743
|
+
.get(...where.values);
|
|
744
|
+
|
|
745
|
+
return Number(row?.count || 0);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Selects matching SQLite rowids before a mutation changes filtered columns.
|
|
750
|
+
*/
|
|
751
|
+
private selectMatchingRowids(): Array<number | bigint> {
|
|
752
|
+
const where = this.createWhereClause();
|
|
753
|
+
const sql = `SELECT rowid FROM ${quoteIdentifier(this.tableName)} ${where.sql}`;
|
|
754
|
+
return this.database
|
|
755
|
+
.prepare(sql)
|
|
756
|
+
.all(...where.values)
|
|
757
|
+
.map((row) => row.rowid as number | bigint);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Creates the SQL WHERE clause.
|
|
762
|
+
*/
|
|
763
|
+
private createWhereClause(): { readonly sql: string; readonly values: ReadonlyArray<unknown> } {
|
|
764
|
+
const parts: Array<string> = [];
|
|
765
|
+
const values: Array<unknown> = [];
|
|
766
|
+
|
|
767
|
+
for (const filter of this.filters) {
|
|
768
|
+
const condition = createFilterCondition(this.tableName, filter);
|
|
769
|
+
parts.push(condition.sql);
|
|
770
|
+
values.push(...condition.values);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
for (const filter of this.orFilters) {
|
|
774
|
+
const condition = createOrFilterCondition(this.tableName, filter);
|
|
775
|
+
if (!condition) {
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
parts.push(condition.sql);
|
|
780
|
+
values.push(...condition.values);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
return {
|
|
784
|
+
sql: parts.length > 0 ? `WHERE ${parts.join(' AND ')}` : '',
|
|
785
|
+
values,
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* Creates the SQL ORDER BY clause.
|
|
791
|
+
*/
|
|
792
|
+
private createOrderByClause(): string {
|
|
793
|
+
if (this.orders.length === 0) {
|
|
794
|
+
return '';
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
const orderParts: Array<string> = [];
|
|
798
|
+
for (const order of this.orders) {
|
|
799
|
+
const quotedColumn = quoteIdentifier(order.column);
|
|
800
|
+
const direction = order.ascending ? 'ASC' : 'DESC';
|
|
801
|
+
|
|
802
|
+
if (order.nullsFirst === true) {
|
|
803
|
+
orderParts.push(`${quotedColumn} IS NOT NULL ASC`);
|
|
804
|
+
} else if (order.nullsFirst === false) {
|
|
805
|
+
orderParts.push(`${quotedColumn} IS NULL ASC`);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
orderParts.push(`${quotedColumn} ${direction}`);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
return `ORDER BY ${orderParts.join(', ')}`;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Creates the SQL LIMIT/OFFSET clause.
|
|
816
|
+
*/
|
|
817
|
+
private createLimitClause(): { readonly sql: string; readonly values: ReadonlyArray<unknown> } {
|
|
818
|
+
if (this.limitCount === null) {
|
|
819
|
+
return { sql: '', values: [] };
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
if (this.offsetCount === null) {
|
|
823
|
+
return { sql: 'LIMIT ?', values: [this.limitCount] };
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
return { sql: 'LIMIT ? OFFSET ?', values: [this.limitCount, this.offsetCount] };
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* Extracts column names referenced in OR filters.
|
|
831
|
+
*/
|
|
832
|
+
private extractOrFilterColumns(): Array<string> {
|
|
833
|
+
return this.orFilters.flatMap((filter) =>
|
|
834
|
+
splitPostgrestOrFilter(filter)
|
|
835
|
+
.map(parsePostgrestFilter)
|
|
836
|
+
.filter((parsedFilter): parsedFilter is ParsedPostgrestFilter => parsedFilter !== null)
|
|
837
|
+
.map((parsedFilter) => parsedFilter.column),
|
|
838
|
+
);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* Parsed PostgREST filter expression.
|
|
844
|
+
*/
|
|
845
|
+
type ParsedPostgrestFilter = {
|
|
846
|
+
readonly column: string;
|
|
847
|
+
readonly operator: string;
|
|
848
|
+
readonly value: string;
|
|
849
|
+
};
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* Ensures a table and all required columns exist.
|
|
853
|
+
*/
|
|
854
|
+
function ensureTable(database: BetterSqliteDatabase, tableName: string, requiredColumns: ReadonlyArray<string>): void {
|
|
855
|
+
const tableBaseName = resolveTableBaseName(tableName);
|
|
856
|
+
const primaryKey = TEXT_PRIMARY_KEY_TABLES.has(tableBaseName)
|
|
857
|
+
? '"id" TEXT PRIMARY KEY'
|
|
858
|
+
: '"id" INTEGER PRIMARY KEY AUTOINCREMENT';
|
|
859
|
+
|
|
860
|
+
database.exec(`CREATE TABLE IF NOT EXISTS ${quoteIdentifier(tableName)} (${primaryKey})`);
|
|
861
|
+
|
|
862
|
+
const existingColumns = new Set(
|
|
863
|
+
database
|
|
864
|
+
.prepare(`PRAGMA table_info(${quoteIdentifier(tableName)})`)
|
|
865
|
+
.all()
|
|
866
|
+
.map((row) => String(row.name)),
|
|
867
|
+
);
|
|
868
|
+
const columnsToEnsure = uniqueStrings([...requiredColumns, ...resolveUniqueIndexColumns(tableBaseName)]).filter(
|
|
869
|
+
(column) => column !== '*' && column !== 'id',
|
|
870
|
+
);
|
|
871
|
+
|
|
872
|
+
for (const column of columnsToEnsure) {
|
|
873
|
+
if (existingColumns.has(column)) {
|
|
874
|
+
continue;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
database.exec(
|
|
878
|
+
`ALTER TABLE ${quoteIdentifier(tableName)} ADD COLUMN ${quoteIdentifier(column)} ${resolveSqliteColumnType(column)}`,
|
|
879
|
+
);
|
|
880
|
+
existingColumns.add(column);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
ensureUniqueIndexes(database, tableName, tableBaseName);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* Creates known unique indexes after required columns exist.
|
|
888
|
+
*/
|
|
889
|
+
function ensureUniqueIndexes(database: BetterSqliteDatabase, tableName: string, tableBaseName: string): void {
|
|
890
|
+
const uniqueIndexes = UNIQUE_INDEX_COLUMNS_BY_TABLE.get(tableBaseName) || [];
|
|
891
|
+
|
|
892
|
+
for (const columns of uniqueIndexes) {
|
|
893
|
+
const indexName = `idx_${sanitizeSqlIdentifier(tableName)}_${columns.join('_')}_unique`;
|
|
894
|
+
const columnSql = columns.map(quoteIdentifier).join(', ');
|
|
895
|
+
database.exec(`CREATE UNIQUE INDEX IF NOT EXISTS ${quoteIdentifier(indexName)} ON ${quoteIdentifier(tableName)} (${columnSql})`);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* Inserts one row into the table.
|
|
901
|
+
*/
|
|
902
|
+
function insertRow(
|
|
903
|
+
database: BetterSqliteDatabase,
|
|
904
|
+
tableName: string,
|
|
905
|
+
row: Record<string, unknown>,
|
|
906
|
+
): { readonly lastInsertRowid: number | bigint } {
|
|
907
|
+
const columns = Object.keys(row).filter((column) => row[column] !== undefined);
|
|
908
|
+
|
|
909
|
+
if (columns.length === 0) {
|
|
910
|
+
return database.prepare(`INSERT INTO ${quoteIdentifier(tableName)} DEFAULT VALUES`).run();
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
const placeholders = columns.map(() => '?').join(', ');
|
|
914
|
+
const values = columns.map((column) => serializeValue(tableName, column, row[column]));
|
|
915
|
+
const sql = `INSERT INTO ${quoteIdentifier(tableName)} (${columns.map(quoteIdentifier).join(', ')}) VALUES (${placeholders})`;
|
|
916
|
+
|
|
917
|
+
return database.prepare(sql).run(...values);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* Updates one row by SQLite rowid.
|
|
922
|
+
*/
|
|
923
|
+
function updateRowid(
|
|
924
|
+
database: BetterSqliteDatabase,
|
|
925
|
+
tableName: string,
|
|
926
|
+
rowid: number | bigint,
|
|
927
|
+
row: Record<string, unknown>,
|
|
928
|
+
): void {
|
|
929
|
+
const columns = Object.keys(row).filter((column) => column !== 'id' && row[column] !== undefined);
|
|
930
|
+
if (columns.length === 0) {
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
const assignments = columns.map((column) => `${quoteIdentifier(column)} = ?`).join(', ');
|
|
935
|
+
const values = columns.map((column) => serializeValue(tableName, column, row[column]));
|
|
936
|
+
database.prepare(`UPDATE ${quoteIdentifier(tableName)} SET ${assignments} WHERE rowid = ?`).run(...values, rowid);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* Finds the rowid matching an upsert conflict target.
|
|
941
|
+
*/
|
|
942
|
+
function findConflictRowid(
|
|
943
|
+
database: BetterSqliteDatabase,
|
|
944
|
+
tableName: string,
|
|
945
|
+
row: Record<string, unknown>,
|
|
946
|
+
conflictColumns: ReadonlyArray<string>,
|
|
947
|
+
): number | bigint | null {
|
|
948
|
+
if (conflictColumns.some((column) => row[column] === undefined)) {
|
|
949
|
+
return null;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
const conditions = conflictColumns.map((column) => `${quoteIdentifier(column)} = ?`).join(' AND ');
|
|
953
|
+
const values = conflictColumns.map((column) => serializeValue(tableName, column, row[column]));
|
|
954
|
+
const result = database.prepare(`SELECT rowid FROM ${quoteIdentifier(tableName)} WHERE ${conditions} LIMIT 1`).get(...values);
|
|
955
|
+
|
|
956
|
+
return result ? (result.rowid as number | bigint) : null;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* Selects rows by rowids for a mutation returning clause.
|
|
961
|
+
*/
|
|
962
|
+
function selectRowsByRowids(
|
|
963
|
+
database: BetterSqliteDatabase,
|
|
964
|
+
tableName: string,
|
|
965
|
+
rowids: ReadonlyArray<number | bigint>,
|
|
966
|
+
selectedColumns: ReadonlyArray<string>,
|
|
967
|
+
): Array<Record<string, unknown>> {
|
|
968
|
+
if (rowids.length === 0) {
|
|
969
|
+
return [];
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
ensureTable(database, tableName, selectedColumns);
|
|
973
|
+
const placeholders = rowids.map(() => '?').join(', ');
|
|
974
|
+
const rows = database
|
|
975
|
+
.prepare(
|
|
976
|
+
`SELECT ${createSelectExpression(selectedColumns)} FROM ${quoteIdentifier(tableName)} WHERE rowid IN (${placeholders})`,
|
|
977
|
+
)
|
|
978
|
+
.all(...rowids);
|
|
979
|
+
|
|
980
|
+
return rows.map((row) => deserializeRow(tableName, row));
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* Creates SQL for one simple filter.
|
|
985
|
+
*/
|
|
986
|
+
function createFilterCondition(
|
|
987
|
+
tableName: string,
|
|
988
|
+
filter: LocalSqliteFilter,
|
|
989
|
+
): { readonly sql: string; readonly values: ReadonlyArray<unknown> } {
|
|
990
|
+
const column = quoteIdentifier(filter.column);
|
|
991
|
+
const value = serializeValue(tableName, filter.column, filter.value);
|
|
992
|
+
|
|
993
|
+
switch (filter.operator) {
|
|
994
|
+
case 'eq':
|
|
995
|
+
return value === null ? { sql: `${column} IS NULL`, values: [] } : { sql: `${column} = ?`, values: [value] };
|
|
996
|
+
case 'neq':
|
|
997
|
+
return value === null
|
|
998
|
+
? { sql: `${column} IS NOT NULL`, values: [] }
|
|
999
|
+
: { sql: `${column} <> ?`, values: [value] };
|
|
1000
|
+
case 'is':
|
|
1001
|
+
return filter.value === null ? { sql: `${column} IS NULL`, values: [] } : { sql: `${column} IS ?`, values: [value] };
|
|
1002
|
+
case 'not-is':
|
|
1003
|
+
return filter.value === null ? { sql: `${column} IS NOT NULL`, values: [] } : { sql: `${column} IS NOT ?`, values: [value] };
|
|
1004
|
+
case 'in': {
|
|
1005
|
+
const values = Array.isArray(filter.value) ? filter.value.map((item) => serializeValue(tableName, filter.column, item)) : [];
|
|
1006
|
+
if (values.length === 0) {
|
|
1007
|
+
return { sql: '0 = 1', values: [] };
|
|
1008
|
+
}
|
|
1009
|
+
return { sql: `${column} IN (${values.map(() => '?').join(', ')})`, values };
|
|
1010
|
+
}
|
|
1011
|
+
case 'lt':
|
|
1012
|
+
return { sql: `${column} < ?`, values: [value] };
|
|
1013
|
+
case 'lte':
|
|
1014
|
+
return { sql: `${column} <= ?`, values: [value] };
|
|
1015
|
+
case 'gt':
|
|
1016
|
+
return { sql: `${column} > ?`, values: [value] };
|
|
1017
|
+
case 'gte':
|
|
1018
|
+
return { sql: `${column} >= ?`, values: [value] };
|
|
1019
|
+
case 'like':
|
|
1020
|
+
return { sql: `${column} LIKE ? ESCAPE '\\'`, values: [value] };
|
|
1021
|
+
case 'ilike':
|
|
1022
|
+
return { sql: `LOWER(${column}) LIKE LOWER(?) ESCAPE '\\'`, values: [value] };
|
|
1023
|
+
default:
|
|
1024
|
+
return { sql: '1 = 1', values: [] };
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* Creates SQL for one PostgREST `.or(...)` filter.
|
|
1030
|
+
*/
|
|
1031
|
+
function createOrFilterCondition(
|
|
1032
|
+
tableName: string,
|
|
1033
|
+
filter: string,
|
|
1034
|
+
): { readonly sql: string; readonly values: ReadonlyArray<unknown> } | null {
|
|
1035
|
+
const conditions: Array<string> = [];
|
|
1036
|
+
const values: Array<unknown> = [];
|
|
1037
|
+
|
|
1038
|
+
for (const part of splitPostgrestOrFilter(filter)) {
|
|
1039
|
+
const parsedFilter = parsePostgrestFilter(part);
|
|
1040
|
+
if (!parsedFilter) {
|
|
1041
|
+
continue;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
if (parsedFilter.operator === 'cs') {
|
|
1045
|
+
conditions.push('0 = 1');
|
|
1046
|
+
continue;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
const condition = createFilterCondition(tableName, {
|
|
1050
|
+
column: parsedFilter.column,
|
|
1051
|
+
operator: normalizePostgrestOperator(parsedFilter.operator),
|
|
1052
|
+
value: decodePostgrestFilterValue(parsedFilter.value),
|
|
1053
|
+
});
|
|
1054
|
+
conditions.push(condition.sql);
|
|
1055
|
+
values.push(...condition.values);
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
if (conditions.length === 0) {
|
|
1059
|
+
return null;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
return {
|
|
1063
|
+
sql: `(${conditions.join(' OR ')})`,
|
|
1064
|
+
values,
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/**
|
|
1069
|
+
* Splits a PostgREST OR filter while keeping JSON literals intact.
|
|
1070
|
+
*/
|
|
1071
|
+
function splitPostgrestOrFilter(filter: string): Array<string> {
|
|
1072
|
+
const parts: Array<string> = [];
|
|
1073
|
+
let current = '';
|
|
1074
|
+
let depth = 0;
|
|
1075
|
+
let isInsideString = false;
|
|
1076
|
+
|
|
1077
|
+
for (let index = 0; index < filter.length; index++) {
|
|
1078
|
+
const character = filter[index]!;
|
|
1079
|
+
const previousCharacter = filter[index - 1];
|
|
1080
|
+
|
|
1081
|
+
if (character === '"' && previousCharacter !== '\\') {
|
|
1082
|
+
isInsideString = !isInsideString;
|
|
1083
|
+
} else if (!isInsideString && (character === '{' || character === '[')) {
|
|
1084
|
+
depth++;
|
|
1085
|
+
} else if (!isInsideString && (character === '}' || character === ']')) {
|
|
1086
|
+
depth--;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
if (character === ',' && depth === 0 && !isInsideString) {
|
|
1090
|
+
parts.push(current);
|
|
1091
|
+
current = '';
|
|
1092
|
+
continue;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
current += character;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
if (current) {
|
|
1099
|
+
parts.push(current);
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
return parts.map((part) => part.trim()).filter(Boolean);
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
/**
|
|
1106
|
+
* Parses one PostgREST filter expression.
|
|
1107
|
+
*/
|
|
1108
|
+
function parsePostgrestFilter(filter: string): ParsedPostgrestFilter | null {
|
|
1109
|
+
const match = /^([^.]*)\.([a-z]+)\.([\s\S]*)$/iu.exec(filter.trim());
|
|
1110
|
+
if (!match) {
|
|
1111
|
+
return null;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
return {
|
|
1115
|
+
column: match[1]!,
|
|
1116
|
+
operator: match[2]!.toLowerCase(),
|
|
1117
|
+
value: match[3]!,
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/**
|
|
1122
|
+
* Converts PostgREST operators into internal filter operators.
|
|
1123
|
+
*/
|
|
1124
|
+
function normalizePostgrestOperator(operator: string): LocalSqliteFilter['operator'] {
|
|
1125
|
+
switch (operator) {
|
|
1126
|
+
case 'neq':
|
|
1127
|
+
return 'neq';
|
|
1128
|
+
case 'ilike':
|
|
1129
|
+
return 'ilike';
|
|
1130
|
+
case 'like':
|
|
1131
|
+
return 'like';
|
|
1132
|
+
case 'lt':
|
|
1133
|
+
return 'lt';
|
|
1134
|
+
case 'lte':
|
|
1135
|
+
return 'lte';
|
|
1136
|
+
case 'gt':
|
|
1137
|
+
return 'gt';
|
|
1138
|
+
case 'gte':
|
|
1139
|
+
return 'gte';
|
|
1140
|
+
case 'eq':
|
|
1141
|
+
default:
|
|
1142
|
+
return 'eq';
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/**
|
|
1147
|
+
* Decodes a PostgREST filter value when URL-encoded by callers.
|
|
1148
|
+
*/
|
|
1149
|
+
function decodePostgrestFilterValue(value: string): string {
|
|
1150
|
+
try {
|
|
1151
|
+
return decodeURIComponent(value);
|
|
1152
|
+
} catch {
|
|
1153
|
+
return value;
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/**
|
|
1158
|
+
* Creates a select expression from parsed columns.
|
|
1159
|
+
*/
|
|
1160
|
+
function createSelectExpression(columns: ReadonlyArray<string>): string {
|
|
1161
|
+
if (columns.length === 0 || columns.includes('*')) {
|
|
1162
|
+
return '*';
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
return columns.map(quoteIdentifier).join(', ');
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
/**
|
|
1169
|
+
* Parses a simple Supabase select column list.
|
|
1170
|
+
*/
|
|
1171
|
+
function parseSelectedColumns(columns: string): Array<string> {
|
|
1172
|
+
const trimmedColumns = columns.trim();
|
|
1173
|
+
if (!trimmedColumns || trimmedColumns === '*') {
|
|
1174
|
+
return ['*'];
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
return trimmedColumns
|
|
1178
|
+
.split(',')
|
|
1179
|
+
.map((column) => column.trim())
|
|
1180
|
+
.filter(Boolean)
|
|
1181
|
+
.map((column) => column.split(':').pop() || column)
|
|
1182
|
+
.map((column) => column.replace(/\s+/g, ''))
|
|
1183
|
+
.filter((column) => /^[A-Za-z_][A-Za-z0-9_]*$/u.test(column));
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
/**
|
|
1187
|
+
* Converts mutation payloads into a uniform array of records.
|
|
1188
|
+
*/
|
|
1189
|
+
function normalizeMutationRows(values: TODO_any): Array<Record<string, unknown>> {
|
|
1190
|
+
if (Array.isArray(values)) {
|
|
1191
|
+
return values.map((row) => stripUndefinedValues(row || {}));
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
return [stripUndefinedValues(values || {})];
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
/**
|
|
1198
|
+
* Removes undefined values because Supabase omits them from mutation payloads.
|
|
1199
|
+
*/
|
|
1200
|
+
function stripUndefinedValues(values: Record<string, unknown>): Record<string, unknown> {
|
|
1201
|
+
const result: Record<string, unknown> = {};
|
|
1202
|
+
|
|
1203
|
+
for (const [key, value] of Object.entries(values)) {
|
|
1204
|
+
if (value !== undefined) {
|
|
1205
|
+
result[key] = value;
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
return result;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
/**
|
|
1213
|
+
* Adds SQLite-side defaults that are normally supplied by PostgreSQL migrations.
|
|
1214
|
+
*/
|
|
1215
|
+
function withInsertDefaults(tableBaseName: string, row: Record<string, unknown>): Record<string, unknown> {
|
|
1216
|
+
const nowIso = new Date().toISOString();
|
|
1217
|
+
const result = { ...row };
|
|
1218
|
+
|
|
1219
|
+
if (result.createdAt === undefined) {
|
|
1220
|
+
result.createdAt = nowIso;
|
|
1221
|
+
}
|
|
1222
|
+
if (result.updatedAt === undefined && tableBaseName !== 'AgentHistory' && tableBaseName !== 'ChatHistory') {
|
|
1223
|
+
result.updatedAt = nowIso;
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
switch (tableBaseName) {
|
|
1227
|
+
case 'Agent':
|
|
1228
|
+
result.visibility ??= 'PRIVATE';
|
|
1229
|
+
result.folderId ??= null;
|
|
1230
|
+
result.sortOrder ??= Date.now();
|
|
1231
|
+
result.deletedAt ??= null;
|
|
1232
|
+
result.preparedModelRequirements ??= null;
|
|
1233
|
+
break;
|
|
1234
|
+
case 'AgentFolder':
|
|
1235
|
+
result.parentId ??= null;
|
|
1236
|
+
result.sortOrder ??= Date.now();
|
|
1237
|
+
result.deletedAt ??= null;
|
|
1238
|
+
result.icon ??= null;
|
|
1239
|
+
result.color ??= null;
|
|
1240
|
+
break;
|
|
1241
|
+
case 'User':
|
|
1242
|
+
result.isAdmin ??= false;
|
|
1243
|
+
result.profileImageUrl ??= null;
|
|
1244
|
+
break;
|
|
1245
|
+
case 'UserChat':
|
|
1246
|
+
result.messages ??= [];
|
|
1247
|
+
result.source ??= 'WEB_UI';
|
|
1248
|
+
result.title ??= null;
|
|
1249
|
+
result.draftMessage ??= null;
|
|
1250
|
+
break;
|
|
1251
|
+
case 'UserChatJob':
|
|
1252
|
+
result.parameters ??= {};
|
|
1253
|
+
result.queuedAt ??= nowIso;
|
|
1254
|
+
result.attemptCount ??= 0;
|
|
1255
|
+
break;
|
|
1256
|
+
case 'UserChatTimeout':
|
|
1257
|
+
result.parameters ??= {};
|
|
1258
|
+
result.queuedAt ??= nowIso;
|
|
1259
|
+
result.attemptCount ??= 0;
|
|
1260
|
+
result.runCount ??= 0;
|
|
1261
|
+
break;
|
|
1262
|
+
case 'ApiTokens':
|
|
1263
|
+
result.isRevoked ??= false;
|
|
1264
|
+
break;
|
|
1265
|
+
case 'Wallet':
|
|
1266
|
+
result.isUserScoped ??= true;
|
|
1267
|
+
result.isGlobal ??= false;
|
|
1268
|
+
result.deletedAt ??= null;
|
|
1269
|
+
break;
|
|
1270
|
+
case 'UserMemory':
|
|
1271
|
+
result.isGlobal ??= false;
|
|
1272
|
+
result.deletedAt ??= null;
|
|
1273
|
+
break;
|
|
1274
|
+
case 'ShareTargetPayload':
|
|
1275
|
+
result.attachments ??= [];
|
|
1276
|
+
result.consumedAt ??= null;
|
|
1277
|
+
break;
|
|
1278
|
+
case 'UserPushSubscription':
|
|
1279
|
+
result.isChatFocused ??= false;
|
|
1280
|
+
break;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
return result;
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
/**
|
|
1287
|
+
* Serializes one value for SQLite storage.
|
|
1288
|
+
*/
|
|
1289
|
+
function serializeValue(tableName: string, column: string, value: unknown): unknown {
|
|
1290
|
+
if (value === undefined) {
|
|
1291
|
+
return undefined;
|
|
1292
|
+
}
|
|
1293
|
+
if (value === null) {
|
|
1294
|
+
return null;
|
|
1295
|
+
}
|
|
1296
|
+
if (isJsonColumn(tableName, column)) {
|
|
1297
|
+
return typeof value === 'string' ? value : JSON.stringify(value);
|
|
1298
|
+
}
|
|
1299
|
+
if (BOOLEAN_COLUMNS.has(column)) {
|
|
1300
|
+
return value ? 1 : 0;
|
|
1301
|
+
}
|
|
1302
|
+
return value;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
/**
|
|
1306
|
+
* Deserializes one SQLite row into Supabase-like row values.
|
|
1307
|
+
*/
|
|
1308
|
+
function deserializeRow(tableName: string, row: Record<string, unknown>): Record<string, unknown> {
|
|
1309
|
+
const result: Record<string, unknown> = {};
|
|
1310
|
+
|
|
1311
|
+
for (const [column, value] of Object.entries(row)) {
|
|
1312
|
+
if (value === null || value === undefined) {
|
|
1313
|
+
result[column] = null;
|
|
1314
|
+
} else if (isJsonColumn(tableName, column) && typeof value === 'string') {
|
|
1315
|
+
result[column] = parseJsonValue(value);
|
|
1316
|
+
} else if (BOOLEAN_COLUMNS.has(column)) {
|
|
1317
|
+
result[column] = Boolean(value);
|
|
1318
|
+
} else {
|
|
1319
|
+
result[column] = value;
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
return result;
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
/**
|
|
1327
|
+
* Parses JSON while preserving invalid strings.
|
|
1328
|
+
*/
|
|
1329
|
+
function parseJsonValue(value: string): unknown {
|
|
1330
|
+
try {
|
|
1331
|
+
return JSON.parse(value);
|
|
1332
|
+
} catch {
|
|
1333
|
+
return value;
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
/**
|
|
1338
|
+
* Resolves whether a column is JSON for a specific table.
|
|
1339
|
+
*/
|
|
1340
|
+
function isJsonColumn(tableName: string, column: string): boolean {
|
|
1341
|
+
return JSON_COLUMNS_BY_TABLE.get(resolveTableBaseName(tableName))?.has(column) || false;
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
/**
|
|
1345
|
+
* Resolves SQLite column affinity for dynamically added columns.
|
|
1346
|
+
*/
|
|
1347
|
+
function resolveSqliteColumnType(column: string): string {
|
|
1348
|
+
if (BOOLEAN_COLUMNS.has(column)) {
|
|
1349
|
+
return 'INTEGER';
|
|
1350
|
+
}
|
|
1351
|
+
if (
|
|
1352
|
+
column === 'id' ||
|
|
1353
|
+
column.endsWith('Id') ||
|
|
1354
|
+
column.endsWith('Count') ||
|
|
1355
|
+
column.endsWith('Ms') ||
|
|
1356
|
+
column.endsWith('Bytes') ||
|
|
1357
|
+
column === 'sortOrder' ||
|
|
1358
|
+
column === 'attemptCount' ||
|
|
1359
|
+
column === 'runCount' ||
|
|
1360
|
+
column === 'value'
|
|
1361
|
+
) {
|
|
1362
|
+
return 'INTEGER';
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
return 'TEXT';
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
/**
|
|
1369
|
+
* Resolves a table base name from the actual prefixed table name.
|
|
1370
|
+
*/
|
|
1371
|
+
function resolveTableBaseName(tableName: string): string {
|
|
1372
|
+
const knownTableNames = uniqueStrings([
|
|
1373
|
+
'_Server',
|
|
1374
|
+
...Array.from(JSON_COLUMNS_BY_TABLE.keys()),
|
|
1375
|
+
...Array.from(UNIQUE_INDEX_COLUMNS_BY_TABLE.keys()),
|
|
1376
|
+
...Array.from(TEXT_PRIMARY_KEY_TABLES),
|
|
1377
|
+
'AgentFolder',
|
|
1378
|
+
'CustomStylesheet',
|
|
1379
|
+
'CustomJavascript',
|
|
1380
|
+
'CalendarActivity',
|
|
1381
|
+
]).sort((left, right) => right.length - left.length);
|
|
1382
|
+
|
|
1383
|
+
return knownTableNames.find((knownTableName) => tableName.endsWith(knownTableName)) || tableName;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
/**
|
|
1387
|
+
* Resolves columns participating in known unique indexes.
|
|
1388
|
+
*/
|
|
1389
|
+
function resolveUniqueIndexColumns(tableBaseName: string): Array<string> {
|
|
1390
|
+
return (UNIQUE_INDEX_COLUMNS_BY_TABLE.get(tableBaseName) || []).flatMap((columns) => [...columns]);
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
/**
|
|
1394
|
+
* Resolves upsert conflict columns.
|
|
1395
|
+
*/
|
|
1396
|
+
function resolveUpsertConflictColumns(
|
|
1397
|
+
tableBaseName: string,
|
|
1398
|
+
options: LocalSqliteUpsertOptions,
|
|
1399
|
+
): ReadonlyArray<string> {
|
|
1400
|
+
if (options.onConflict) {
|
|
1401
|
+
return options.onConflict
|
|
1402
|
+
.split(',')
|
|
1403
|
+
.map((column) => column.trim())
|
|
1404
|
+
.filter(Boolean);
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
return DEFAULT_UPSERT_CONFLICT_COLUMNS_BY_TABLE.get(tableBaseName) || [];
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
/**
|
|
1411
|
+
* Converts SQLite errors into Supabase-like errors.
|
|
1412
|
+
*/
|
|
1413
|
+
function normalizeSqliteError(error: unknown): LocalSqliteError {
|
|
1414
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1415
|
+
const sqliteCode =
|
|
1416
|
+
typeof error === 'object' && error !== null && typeof (error as { code?: unknown }).code === 'string'
|
|
1417
|
+
? (error as { code: string }).code
|
|
1418
|
+
: undefined;
|
|
1419
|
+
|
|
1420
|
+
return {
|
|
1421
|
+
code: sqliteCode === 'SQLITE_CONSTRAINT_UNIQUE' || sqliteCode === 'SQLITE_CONSTRAINT_PRIMARYKEY' ? '23505' : sqliteCode,
|
|
1422
|
+
message,
|
|
1423
|
+
};
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
/**
|
|
1427
|
+
* Quotes one SQLite identifier.
|
|
1428
|
+
*/
|
|
1429
|
+
function quoteIdentifier(identifier: string): string {
|
|
1430
|
+
return `"${identifier.replace(/"/g, '""')}"`;
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
/**
|
|
1434
|
+
* Creates a safe identifier suffix for generated index names.
|
|
1435
|
+
*/
|
|
1436
|
+
function sanitizeSqlIdentifier(identifier: string): string {
|
|
1437
|
+
return identifier.replace(/[^A-Za-z0-9_]/g, '_');
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
/**
|
|
1441
|
+
* Deduplicates strings while preserving order.
|
|
1442
|
+
*/
|
|
1443
|
+
function uniqueStrings(values: ReadonlyArray<string>): Array<string> {
|
|
1444
|
+
return Array.from(new Set(values.filter(Boolean)));
|
|
1445
|
+
}
|