@powersync/common 0.0.0-dev-20260128023420 → 0.0.0-dev-20260128170746
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/README.md +5 -1
- package/dist/bundle.cjs +1433 -386
- package/dist/bundle.cjs.map +1 -1
- package/dist/bundle.mjs +1425 -387
- package/dist/bundle.mjs.map +1 -1
- package/dist/bundle.node.cjs +1433 -386
- package/dist/bundle.node.cjs.map +1 -1
- package/dist/bundle.node.mjs +1425 -387
- package/dist/bundle.node.mjs.map +1 -1
- package/dist/index.d.cts +617 -44
- package/lib/attachments/AttachmentContext.d.ts +86 -0
- package/lib/attachments/AttachmentContext.js +229 -0
- package/lib/attachments/AttachmentContext.js.map +1 -0
- package/lib/attachments/AttachmentErrorHandler.d.ts +31 -0
- package/lib/attachments/AttachmentErrorHandler.js +2 -0
- package/lib/attachments/AttachmentErrorHandler.js.map +1 -0
- package/lib/attachments/AttachmentQueue.d.ts +149 -0
- package/lib/attachments/AttachmentQueue.js +362 -0
- package/lib/attachments/AttachmentQueue.js.map +1 -0
- package/lib/attachments/AttachmentService.d.ts +29 -0
- package/lib/attachments/AttachmentService.js +56 -0
- package/lib/attachments/AttachmentService.js.map +1 -0
- package/lib/attachments/LocalStorageAdapter.d.ts +62 -0
- package/lib/attachments/LocalStorageAdapter.js +6 -0
- package/lib/attachments/LocalStorageAdapter.js.map +1 -0
- package/lib/attachments/RemoteStorageAdapter.d.ts +27 -0
- package/lib/attachments/RemoteStorageAdapter.js +2 -0
- package/lib/attachments/RemoteStorageAdapter.js.map +1 -0
- package/lib/attachments/Schema.d.ts +50 -0
- package/lib/attachments/Schema.js +62 -0
- package/lib/attachments/Schema.js.map +1 -0
- package/lib/attachments/SyncingService.d.ts +62 -0
- package/lib/attachments/SyncingService.js +168 -0
- package/lib/attachments/SyncingService.js.map +1 -0
- package/lib/attachments/WatchedAttachmentItem.d.ts +17 -0
- package/lib/attachments/WatchedAttachmentItem.js +2 -0
- package/lib/attachments/WatchedAttachmentItem.js.map +1 -0
- package/lib/client/AbstractPowerSyncDatabase.d.ts +8 -1
- package/lib/client/AbstractPowerSyncDatabase.js +16 -3
- package/lib/client/AbstractPowerSyncDatabase.js.map +1 -1
- package/lib/client/sync/stream/AbstractStreamingSyncImplementation.d.ts +7 -12
- package/lib/client/sync/stream/AbstractStreamingSyncImplementation.js +10 -12
- package/lib/client/sync/stream/AbstractStreamingSyncImplementation.js.map +1 -1
- package/lib/client/triggers/MemoryTriggerClaimManager.d.ts +6 -0
- package/lib/client/triggers/MemoryTriggerClaimManager.js +21 -0
- package/lib/client/triggers/MemoryTriggerClaimManager.js.map +1 -0
- package/lib/client/triggers/TriggerManager.d.ts +37 -0
- package/lib/client/triggers/TriggerManagerImpl.d.ts +24 -3
- package/lib/client/triggers/TriggerManagerImpl.js +133 -11
- package/lib/client/triggers/TriggerManagerImpl.js.map +1 -1
- package/lib/index.d.ts +12 -1
- package/lib/index.js +12 -1
- package/lib/index.js.map +1 -1
- package/package.json +4 -3
- package/src/attachments/AttachmentContext.ts +279 -0
- package/src/attachments/AttachmentErrorHandler.ts +34 -0
- package/src/attachments/AttachmentQueue.ts +472 -0
- package/src/attachments/AttachmentService.ts +62 -0
- package/src/attachments/LocalStorageAdapter.ts +72 -0
- package/src/attachments/README.md +718 -0
- package/src/attachments/RemoteStorageAdapter.ts +30 -0
- package/src/attachments/Schema.ts +87 -0
- package/src/attachments/SyncingService.ts +193 -0
- package/src/attachments/WatchedAttachmentItem.ts +19 -0
- package/src/client/AbstractPowerSyncDatabase.ts +19 -4
- package/src/client/sync/stream/AbstractStreamingSyncImplementation.ts +10 -12
- package/src/client/triggers/MemoryTriggerClaimManager.ts +25 -0
- package/src/client/triggers/TriggerManager.ts +50 -6
- package/src/client/triggers/TriggerManagerImpl.ts +177 -13
- package/src/index.ts +13 -1
package/dist/bundle.node.cjs
CHANGED
|
@@ -4,6 +4,1211 @@ var asyncMutex = require('async-mutex');
|
|
|
4
4
|
var eventIterator = require('event-iterator');
|
|
5
5
|
var node_buffer = require('node:buffer');
|
|
6
6
|
|
|
7
|
+
// https://www.sqlite.org/lang_expr.html#castexpr
|
|
8
|
+
exports.ColumnType = void 0;
|
|
9
|
+
(function (ColumnType) {
|
|
10
|
+
ColumnType["TEXT"] = "TEXT";
|
|
11
|
+
ColumnType["INTEGER"] = "INTEGER";
|
|
12
|
+
ColumnType["REAL"] = "REAL";
|
|
13
|
+
})(exports.ColumnType || (exports.ColumnType = {}));
|
|
14
|
+
const text = {
|
|
15
|
+
type: exports.ColumnType.TEXT
|
|
16
|
+
};
|
|
17
|
+
const integer = {
|
|
18
|
+
type: exports.ColumnType.INTEGER
|
|
19
|
+
};
|
|
20
|
+
const real = {
|
|
21
|
+
type: exports.ColumnType.REAL
|
|
22
|
+
};
|
|
23
|
+
// powersync-sqlite-core limits the number of column per table to 1999, due to internal SQLite limits.
|
|
24
|
+
// In earlier versions this was limited to 63.
|
|
25
|
+
const MAX_AMOUNT_OF_COLUMNS = 1999;
|
|
26
|
+
const column = {
|
|
27
|
+
text,
|
|
28
|
+
integer,
|
|
29
|
+
real
|
|
30
|
+
};
|
|
31
|
+
class Column {
|
|
32
|
+
options;
|
|
33
|
+
constructor(options) {
|
|
34
|
+
this.options = options;
|
|
35
|
+
}
|
|
36
|
+
get name() {
|
|
37
|
+
return this.options.name;
|
|
38
|
+
}
|
|
39
|
+
get type() {
|
|
40
|
+
return this.options.type;
|
|
41
|
+
}
|
|
42
|
+
toJSON() {
|
|
43
|
+
return {
|
|
44
|
+
name: this.name,
|
|
45
|
+
type: this.type
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const DEFAULT_INDEX_COLUMN_OPTIONS = {
|
|
51
|
+
ascending: true
|
|
52
|
+
};
|
|
53
|
+
class IndexedColumn {
|
|
54
|
+
options;
|
|
55
|
+
static createAscending(column) {
|
|
56
|
+
return new IndexedColumn({
|
|
57
|
+
name: column,
|
|
58
|
+
ascending: true
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
constructor(options) {
|
|
62
|
+
this.options = { ...DEFAULT_INDEX_COLUMN_OPTIONS, ...options };
|
|
63
|
+
}
|
|
64
|
+
get name() {
|
|
65
|
+
return this.options.name;
|
|
66
|
+
}
|
|
67
|
+
get ascending() {
|
|
68
|
+
return this.options.ascending;
|
|
69
|
+
}
|
|
70
|
+
toJSON(table) {
|
|
71
|
+
return {
|
|
72
|
+
name: this.name,
|
|
73
|
+
ascending: this.ascending,
|
|
74
|
+
type: table.columns.find((column) => column.name === this.name)?.type ?? exports.ColumnType.TEXT
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const DEFAULT_INDEX_OPTIONS = {
|
|
80
|
+
columns: []
|
|
81
|
+
};
|
|
82
|
+
class Index {
|
|
83
|
+
options;
|
|
84
|
+
static createAscending(options, columnNames) {
|
|
85
|
+
return new Index({
|
|
86
|
+
...options,
|
|
87
|
+
columns: columnNames.map((name) => IndexedColumn.createAscending(name))
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
constructor(options) {
|
|
91
|
+
this.options = options;
|
|
92
|
+
this.options = { ...DEFAULT_INDEX_OPTIONS, ...options };
|
|
93
|
+
}
|
|
94
|
+
get name() {
|
|
95
|
+
return this.options.name;
|
|
96
|
+
}
|
|
97
|
+
get columns() {
|
|
98
|
+
return this.options.columns ?? [];
|
|
99
|
+
}
|
|
100
|
+
toJSON(table) {
|
|
101
|
+
return {
|
|
102
|
+
name: this.name,
|
|
103
|
+
columns: this.columns.map((c) => c.toJSON(table))
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const DEFAULT_TABLE_OPTIONS = {
|
|
109
|
+
indexes: [],
|
|
110
|
+
insertOnly: false,
|
|
111
|
+
localOnly: false,
|
|
112
|
+
trackPrevious: false,
|
|
113
|
+
trackMetadata: false,
|
|
114
|
+
ignoreEmptyUpdates: false
|
|
115
|
+
};
|
|
116
|
+
const InvalidSQLCharacters = /["'%,.#\s[\]]/;
|
|
117
|
+
class Table {
|
|
118
|
+
options;
|
|
119
|
+
_mappedColumns;
|
|
120
|
+
static createLocalOnly(options) {
|
|
121
|
+
return new Table({ ...options, localOnly: true, insertOnly: false });
|
|
122
|
+
}
|
|
123
|
+
static createInsertOnly(options) {
|
|
124
|
+
return new Table({ ...options, localOnly: false, insertOnly: true });
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Create a table.
|
|
128
|
+
* @deprecated This was only only included for TableV2 and is no longer necessary.
|
|
129
|
+
* Prefer to use new Table() directly.
|
|
130
|
+
*
|
|
131
|
+
* TODO remove in the next major release.
|
|
132
|
+
*/
|
|
133
|
+
static createTable(name, table) {
|
|
134
|
+
return new Table({
|
|
135
|
+
name,
|
|
136
|
+
columns: table.columns,
|
|
137
|
+
indexes: table.indexes,
|
|
138
|
+
localOnly: table.options.localOnly,
|
|
139
|
+
insertOnly: table.options.insertOnly,
|
|
140
|
+
viewName: table.options.viewName
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
constructor(optionsOrColumns, v2Options) {
|
|
144
|
+
if (this.isTableV1(optionsOrColumns)) {
|
|
145
|
+
this.initTableV1(optionsOrColumns);
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
this.initTableV2(optionsOrColumns, v2Options);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
copyWithName(name) {
|
|
152
|
+
return new Table({
|
|
153
|
+
...this.options,
|
|
154
|
+
name
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
isTableV1(arg) {
|
|
158
|
+
return 'columns' in arg && Array.isArray(arg.columns);
|
|
159
|
+
}
|
|
160
|
+
initTableV1(options) {
|
|
161
|
+
this.options = {
|
|
162
|
+
...options,
|
|
163
|
+
indexes: options.indexes || []
|
|
164
|
+
};
|
|
165
|
+
this.applyDefaultOptions();
|
|
166
|
+
}
|
|
167
|
+
initTableV2(columns, options) {
|
|
168
|
+
const convertedColumns = Object.entries(columns).map(([name, columnInfo]) => new Column({ name, type: columnInfo.type }));
|
|
169
|
+
const convertedIndexes = Object.entries(options?.indexes ?? {}).map(([name, columnNames]) => new Index({
|
|
170
|
+
name,
|
|
171
|
+
columns: columnNames.map((name) => new IndexedColumn({
|
|
172
|
+
name: name.replace(/^-/, ''),
|
|
173
|
+
ascending: !name.startsWith('-')
|
|
174
|
+
}))
|
|
175
|
+
}));
|
|
176
|
+
this.options = {
|
|
177
|
+
name: '',
|
|
178
|
+
columns: convertedColumns,
|
|
179
|
+
indexes: convertedIndexes,
|
|
180
|
+
viewName: options?.viewName,
|
|
181
|
+
insertOnly: options?.insertOnly,
|
|
182
|
+
localOnly: options?.localOnly,
|
|
183
|
+
trackPrevious: options?.trackPrevious,
|
|
184
|
+
trackMetadata: options?.trackMetadata,
|
|
185
|
+
ignoreEmptyUpdates: options?.ignoreEmptyUpdates
|
|
186
|
+
};
|
|
187
|
+
this.applyDefaultOptions();
|
|
188
|
+
this._mappedColumns = columns;
|
|
189
|
+
}
|
|
190
|
+
applyDefaultOptions() {
|
|
191
|
+
this.options.insertOnly ??= DEFAULT_TABLE_OPTIONS.insertOnly;
|
|
192
|
+
this.options.localOnly ??= DEFAULT_TABLE_OPTIONS.localOnly;
|
|
193
|
+
this.options.trackPrevious ??= DEFAULT_TABLE_OPTIONS.trackPrevious;
|
|
194
|
+
this.options.trackMetadata ??= DEFAULT_TABLE_OPTIONS.trackMetadata;
|
|
195
|
+
this.options.ignoreEmptyUpdates ??= DEFAULT_TABLE_OPTIONS.ignoreEmptyUpdates;
|
|
196
|
+
}
|
|
197
|
+
get name() {
|
|
198
|
+
return this.options.name;
|
|
199
|
+
}
|
|
200
|
+
get viewNameOverride() {
|
|
201
|
+
return this.options.viewName;
|
|
202
|
+
}
|
|
203
|
+
get viewName() {
|
|
204
|
+
return this.viewNameOverride ?? this.name;
|
|
205
|
+
}
|
|
206
|
+
get columns() {
|
|
207
|
+
return this.options.columns;
|
|
208
|
+
}
|
|
209
|
+
get columnMap() {
|
|
210
|
+
return (this._mappedColumns ??
|
|
211
|
+
this.columns.reduce((hash, column) => {
|
|
212
|
+
hash[column.name] = { type: column.type ?? exports.ColumnType.TEXT };
|
|
213
|
+
return hash;
|
|
214
|
+
}, {}));
|
|
215
|
+
}
|
|
216
|
+
get indexes() {
|
|
217
|
+
return this.options.indexes ?? [];
|
|
218
|
+
}
|
|
219
|
+
get localOnly() {
|
|
220
|
+
return this.options.localOnly;
|
|
221
|
+
}
|
|
222
|
+
get insertOnly() {
|
|
223
|
+
return this.options.insertOnly;
|
|
224
|
+
}
|
|
225
|
+
get trackPrevious() {
|
|
226
|
+
return this.options.trackPrevious;
|
|
227
|
+
}
|
|
228
|
+
get trackMetadata() {
|
|
229
|
+
return this.options.trackMetadata;
|
|
230
|
+
}
|
|
231
|
+
get ignoreEmptyUpdates() {
|
|
232
|
+
return this.options.ignoreEmptyUpdates;
|
|
233
|
+
}
|
|
234
|
+
get internalName() {
|
|
235
|
+
if (this.options.localOnly) {
|
|
236
|
+
return `ps_data_local__${this.name}`;
|
|
237
|
+
}
|
|
238
|
+
return `ps_data__${this.name}`;
|
|
239
|
+
}
|
|
240
|
+
get validName() {
|
|
241
|
+
if (InvalidSQLCharacters.test(this.name)) {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
if (this.viewNameOverride != null && InvalidSQLCharacters.test(this.viewNameOverride)) {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
validate() {
|
|
250
|
+
if (InvalidSQLCharacters.test(this.name)) {
|
|
251
|
+
throw new Error(`Invalid characters in table name: ${this.name}`);
|
|
252
|
+
}
|
|
253
|
+
if (this.viewNameOverride && InvalidSQLCharacters.test(this.viewNameOverride)) {
|
|
254
|
+
throw new Error(`Invalid characters in view name: ${this.viewNameOverride}`);
|
|
255
|
+
}
|
|
256
|
+
if (this.columns.length > MAX_AMOUNT_OF_COLUMNS) {
|
|
257
|
+
throw new Error(`Table has too many columns. The maximum number of columns is ${MAX_AMOUNT_OF_COLUMNS}.`);
|
|
258
|
+
}
|
|
259
|
+
if (this.trackMetadata && this.localOnly) {
|
|
260
|
+
throw new Error(`Can't include metadata for local-only tables.`);
|
|
261
|
+
}
|
|
262
|
+
if (this.trackPrevious != false && this.localOnly) {
|
|
263
|
+
throw new Error(`Can't include old values for local-only tables.`);
|
|
264
|
+
}
|
|
265
|
+
const columnNames = new Set();
|
|
266
|
+
columnNames.add('id');
|
|
267
|
+
for (const column of this.columns) {
|
|
268
|
+
const { name: columnName } = column;
|
|
269
|
+
if (column.name === 'id') {
|
|
270
|
+
throw new Error(`An id column is automatically added, custom id columns are not supported`);
|
|
271
|
+
}
|
|
272
|
+
if (columnNames.has(columnName)) {
|
|
273
|
+
throw new Error(`Duplicate column ${columnName}`);
|
|
274
|
+
}
|
|
275
|
+
if (InvalidSQLCharacters.test(columnName)) {
|
|
276
|
+
throw new Error(`Invalid characters in column name: ${column.name}`);
|
|
277
|
+
}
|
|
278
|
+
columnNames.add(columnName);
|
|
279
|
+
}
|
|
280
|
+
const indexNames = new Set();
|
|
281
|
+
for (const index of this.indexes) {
|
|
282
|
+
if (indexNames.has(index.name)) {
|
|
283
|
+
throw new Error(`Duplicate index ${index.name}`);
|
|
284
|
+
}
|
|
285
|
+
if (InvalidSQLCharacters.test(index.name)) {
|
|
286
|
+
throw new Error(`Invalid characters in index name: ${index.name}`);
|
|
287
|
+
}
|
|
288
|
+
for (const column of index.columns) {
|
|
289
|
+
if (!columnNames.has(column.name)) {
|
|
290
|
+
throw new Error(`Column ${column.name} not found for index ${index.name}`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
indexNames.add(index.name);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
toJSON() {
|
|
297
|
+
const trackPrevious = this.trackPrevious;
|
|
298
|
+
return {
|
|
299
|
+
name: this.name,
|
|
300
|
+
view_name: this.viewName,
|
|
301
|
+
local_only: this.localOnly,
|
|
302
|
+
insert_only: this.insertOnly,
|
|
303
|
+
include_old: trackPrevious && (trackPrevious.columns ?? true),
|
|
304
|
+
include_old_only_when_changed: typeof trackPrevious == 'object' && trackPrevious.onlyWhenChanged == true,
|
|
305
|
+
include_metadata: this.trackMetadata,
|
|
306
|
+
ignore_empty_update: this.ignoreEmptyUpdates,
|
|
307
|
+
columns: this.columns.map((c) => c.toJSON()),
|
|
308
|
+
indexes: this.indexes.map((e) => e.toJSON(this))
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const ATTACHMENT_TABLE = 'attachments';
|
|
314
|
+
/**
|
|
315
|
+
* Maps a database row to an AttachmentRecord.
|
|
316
|
+
*
|
|
317
|
+
* @param row - The database row object
|
|
318
|
+
* @returns The corresponding AttachmentRecord
|
|
319
|
+
*
|
|
320
|
+
* @experimental
|
|
321
|
+
*/
|
|
322
|
+
function attachmentFromSql(row) {
|
|
323
|
+
return {
|
|
324
|
+
id: row.id,
|
|
325
|
+
filename: row.filename,
|
|
326
|
+
localUri: row.local_uri,
|
|
327
|
+
size: row.size,
|
|
328
|
+
mediaType: row.media_type,
|
|
329
|
+
timestamp: row.timestamp,
|
|
330
|
+
metaData: row.meta_data,
|
|
331
|
+
hasSynced: row.has_synced === 1,
|
|
332
|
+
state: row.state
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* AttachmentState represents the current synchronization state of an attachment.
|
|
337
|
+
*
|
|
338
|
+
* @experimental
|
|
339
|
+
*/
|
|
340
|
+
exports.AttachmentState = void 0;
|
|
341
|
+
(function (AttachmentState) {
|
|
342
|
+
AttachmentState[AttachmentState["QUEUED_UPLOAD"] = 0] = "QUEUED_UPLOAD";
|
|
343
|
+
AttachmentState[AttachmentState["QUEUED_DOWNLOAD"] = 1] = "QUEUED_DOWNLOAD";
|
|
344
|
+
AttachmentState[AttachmentState["QUEUED_DELETE"] = 2] = "QUEUED_DELETE";
|
|
345
|
+
AttachmentState[AttachmentState["SYNCED"] = 3] = "SYNCED";
|
|
346
|
+
AttachmentState[AttachmentState["ARCHIVED"] = 4] = "ARCHIVED"; // Attachment has been orphaned, i.e. the associated record has been deleted
|
|
347
|
+
})(exports.AttachmentState || (exports.AttachmentState = {}));
|
|
348
|
+
/**
|
|
349
|
+
* AttachmentTable defines the schema for the attachment queue table.
|
|
350
|
+
*
|
|
351
|
+
* @internal
|
|
352
|
+
*/
|
|
353
|
+
class AttachmentTable extends Table {
|
|
354
|
+
constructor(options) {
|
|
355
|
+
super({
|
|
356
|
+
filename: column.text,
|
|
357
|
+
local_uri: column.text,
|
|
358
|
+
timestamp: column.integer,
|
|
359
|
+
size: column.integer,
|
|
360
|
+
media_type: column.text,
|
|
361
|
+
state: column.integer, // Corresponds to AttachmentState
|
|
362
|
+
has_synced: column.integer,
|
|
363
|
+
meta_data: column.text
|
|
364
|
+
}, {
|
|
365
|
+
...options,
|
|
366
|
+
viewName: options?.viewName ?? ATTACHMENT_TABLE,
|
|
367
|
+
localOnly: true,
|
|
368
|
+
insertOnly: false
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* AttachmentContext provides database operations for managing attachment records.
|
|
375
|
+
*
|
|
376
|
+
* Provides methods to query, insert, update, and delete attachment records with
|
|
377
|
+
* proper transaction management through PowerSync.
|
|
378
|
+
*
|
|
379
|
+
* @internal
|
|
380
|
+
*/
|
|
381
|
+
class AttachmentContext {
|
|
382
|
+
/** PowerSync database instance for executing queries */
|
|
383
|
+
db;
|
|
384
|
+
/** Name of the database table storing attachment records */
|
|
385
|
+
tableName;
|
|
386
|
+
/** Logger instance for diagnostic information */
|
|
387
|
+
logger;
|
|
388
|
+
/** Maximum number of archived attachments to keep before cleanup */
|
|
389
|
+
archivedCacheLimit = 100;
|
|
390
|
+
/**
|
|
391
|
+
* Creates a new AttachmentContext instance.
|
|
392
|
+
*
|
|
393
|
+
* @param db - PowerSync database instance
|
|
394
|
+
* @param tableName - Name of the table storing attachment records. Default: 'attachments'
|
|
395
|
+
* @param logger - Logger instance for diagnostic output
|
|
396
|
+
*/
|
|
397
|
+
constructor(db, tableName = 'attachments', logger, archivedCacheLimit) {
|
|
398
|
+
this.db = db;
|
|
399
|
+
this.tableName = tableName;
|
|
400
|
+
this.logger = logger;
|
|
401
|
+
this.archivedCacheLimit = archivedCacheLimit;
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Retrieves all active attachments that require synchronization.
|
|
405
|
+
* Active attachments include those queued for upload, download, or delete.
|
|
406
|
+
* Results are ordered by timestamp in ascending order.
|
|
407
|
+
*
|
|
408
|
+
* @returns Promise resolving to an array of active attachment records
|
|
409
|
+
*/
|
|
410
|
+
async getActiveAttachments() {
|
|
411
|
+
const attachments = await this.db.getAll(
|
|
412
|
+
/* sql */
|
|
413
|
+
`
|
|
414
|
+
SELECT
|
|
415
|
+
*
|
|
416
|
+
FROM
|
|
417
|
+
${this.tableName}
|
|
418
|
+
WHERE
|
|
419
|
+
state = ?
|
|
420
|
+
OR state = ?
|
|
421
|
+
OR state = ?
|
|
422
|
+
ORDER BY
|
|
423
|
+
timestamp ASC
|
|
424
|
+
`, [exports.AttachmentState.QUEUED_UPLOAD, exports.AttachmentState.QUEUED_DOWNLOAD, exports.AttachmentState.QUEUED_DELETE]);
|
|
425
|
+
return attachments.map(attachmentFromSql);
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Retrieves all archived attachments.
|
|
429
|
+
*
|
|
430
|
+
* Archived attachments are no longer referenced but haven't been permanently deleted.
|
|
431
|
+
* These are candidates for cleanup operations to free up storage space.
|
|
432
|
+
*
|
|
433
|
+
* @returns Promise resolving to an array of archived attachment records
|
|
434
|
+
*/
|
|
435
|
+
async getArchivedAttachments() {
|
|
436
|
+
const attachments = await this.db.getAll(
|
|
437
|
+
/* sql */
|
|
438
|
+
`
|
|
439
|
+
SELECT
|
|
440
|
+
*
|
|
441
|
+
FROM
|
|
442
|
+
${this.tableName}
|
|
443
|
+
WHERE
|
|
444
|
+
state = ?
|
|
445
|
+
ORDER BY
|
|
446
|
+
timestamp ASC
|
|
447
|
+
`, [exports.AttachmentState.ARCHIVED]);
|
|
448
|
+
return attachments.map(attachmentFromSql);
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Retrieves all attachment records regardless of state.
|
|
452
|
+
* Results are ordered by timestamp in ascending order.
|
|
453
|
+
*
|
|
454
|
+
* @returns Promise resolving to an array of all attachment records
|
|
455
|
+
*/
|
|
456
|
+
async getAttachments() {
|
|
457
|
+
const attachments = await this.db.getAll(
|
|
458
|
+
/* sql */
|
|
459
|
+
`
|
|
460
|
+
SELECT
|
|
461
|
+
*
|
|
462
|
+
FROM
|
|
463
|
+
${this.tableName}
|
|
464
|
+
ORDER BY
|
|
465
|
+
timestamp ASC
|
|
466
|
+
`, []);
|
|
467
|
+
return attachments.map(attachmentFromSql);
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Inserts or updates an attachment record within an existing transaction.
|
|
471
|
+
*
|
|
472
|
+
* Performs an upsert operation (INSERT OR REPLACE). Must be called within
|
|
473
|
+
* an active database transaction context.
|
|
474
|
+
*
|
|
475
|
+
* @param attachment - The attachment record to upsert
|
|
476
|
+
* @param context - Active database transaction context
|
|
477
|
+
*/
|
|
478
|
+
async upsertAttachment(attachment, context) {
|
|
479
|
+
await context.execute(
|
|
480
|
+
/* sql */
|
|
481
|
+
`
|
|
482
|
+
INSERT
|
|
483
|
+
OR REPLACE INTO ${this.tableName} (
|
|
484
|
+
id,
|
|
485
|
+
filename,
|
|
486
|
+
local_uri,
|
|
487
|
+
size,
|
|
488
|
+
media_type,
|
|
489
|
+
timestamp,
|
|
490
|
+
state,
|
|
491
|
+
has_synced,
|
|
492
|
+
meta_data
|
|
493
|
+
)
|
|
494
|
+
VALUES
|
|
495
|
+
(?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
496
|
+
`, [
|
|
497
|
+
attachment.id,
|
|
498
|
+
attachment.filename,
|
|
499
|
+
attachment.localUri || null,
|
|
500
|
+
attachment.size || null,
|
|
501
|
+
attachment.mediaType || null,
|
|
502
|
+
attachment.timestamp,
|
|
503
|
+
attachment.state,
|
|
504
|
+
attachment.hasSynced ? 1 : 0,
|
|
505
|
+
attachment.metaData || null
|
|
506
|
+
]);
|
|
507
|
+
}
|
|
508
|
+
async getAttachment(id) {
|
|
509
|
+
const attachment = await this.db.get(
|
|
510
|
+
/* sql */
|
|
511
|
+
`
|
|
512
|
+
SELECT
|
|
513
|
+
*
|
|
514
|
+
FROM
|
|
515
|
+
${this.tableName}
|
|
516
|
+
WHERE
|
|
517
|
+
id = ?
|
|
518
|
+
`, [id]);
|
|
519
|
+
return attachment ? attachmentFromSql(attachment) : undefined;
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* Permanently deletes an attachment record from the database.
|
|
523
|
+
*
|
|
524
|
+
* This operation removes the attachment record but does not delete
|
|
525
|
+
* the associated local or remote files. File deletion should be handled
|
|
526
|
+
* separately through the appropriate storage adapters.
|
|
527
|
+
*
|
|
528
|
+
* @param attachmentId - Unique identifier of the attachment to delete
|
|
529
|
+
*/
|
|
530
|
+
async deleteAttachment(attachmentId) {
|
|
531
|
+
await this.db.writeTransaction((tx) => tx.execute(
|
|
532
|
+
/* sql */
|
|
533
|
+
`
|
|
534
|
+
DELETE FROM ${this.tableName}
|
|
535
|
+
WHERE
|
|
536
|
+
id = ?
|
|
537
|
+
`, [attachmentId]));
|
|
538
|
+
}
|
|
539
|
+
async clearQueue() {
|
|
540
|
+
await this.db.writeTransaction((tx) => tx.execute(/* sql */ ` DELETE FROM ${this.tableName} `));
|
|
541
|
+
}
|
|
542
|
+
async deleteArchivedAttachments(callback) {
|
|
543
|
+
const limit = 1000;
|
|
544
|
+
const results = await this.db.getAll(
|
|
545
|
+
/* sql */
|
|
546
|
+
`
|
|
547
|
+
SELECT
|
|
548
|
+
*
|
|
549
|
+
FROM
|
|
550
|
+
${this.tableName}
|
|
551
|
+
WHERE
|
|
552
|
+
state = ?
|
|
553
|
+
ORDER BY
|
|
554
|
+
timestamp DESC
|
|
555
|
+
LIMIT
|
|
556
|
+
?
|
|
557
|
+
OFFSET
|
|
558
|
+
?
|
|
559
|
+
`, [exports.AttachmentState.ARCHIVED, limit, this.archivedCacheLimit]);
|
|
560
|
+
const archivedAttachments = results.map(attachmentFromSql);
|
|
561
|
+
if (archivedAttachments.length === 0)
|
|
562
|
+
return false;
|
|
563
|
+
await callback?.(archivedAttachments);
|
|
564
|
+
this.logger.info(`Deleting ${archivedAttachments.length} archived attachments. Archived attachment exceeds cache archiveCacheLimit of ${this.archivedCacheLimit}.`);
|
|
565
|
+
const ids = archivedAttachments.map((attachment) => attachment.id);
|
|
566
|
+
await this.db.execute(
|
|
567
|
+
/* sql */
|
|
568
|
+
`
|
|
569
|
+
DELETE FROM ${this.tableName}
|
|
570
|
+
WHERE
|
|
571
|
+
id IN (
|
|
572
|
+
SELECT
|
|
573
|
+
json_each.value
|
|
574
|
+
FROM
|
|
575
|
+
json_each (?)
|
|
576
|
+
);
|
|
577
|
+
`, [JSON.stringify(ids)]);
|
|
578
|
+
this.logger.info(`Deleted ${archivedAttachments.length} archived attachments`);
|
|
579
|
+
return archivedAttachments.length < limit;
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Saves multiple attachment records in a single transaction.
|
|
583
|
+
*
|
|
584
|
+
* All updates are saved in a single batch after processing.
|
|
585
|
+
* If the attachments array is empty, no database operations are performed.
|
|
586
|
+
*
|
|
587
|
+
* @param attachments - Array of attachment records to save
|
|
588
|
+
*/
|
|
589
|
+
async saveAttachments(attachments) {
|
|
590
|
+
if (attachments.length === 0) {
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
await this.db.writeTransaction(async (tx) => {
|
|
594
|
+
for (const attachment of attachments) {
|
|
595
|
+
await this.upsertAttachment(attachment, tx);
|
|
596
|
+
}
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
exports.WatchedQueryListenerEvent = void 0;
|
|
602
|
+
(function (WatchedQueryListenerEvent) {
|
|
603
|
+
WatchedQueryListenerEvent["ON_DATA"] = "onData";
|
|
604
|
+
WatchedQueryListenerEvent["ON_ERROR"] = "onError";
|
|
605
|
+
WatchedQueryListenerEvent["ON_STATE_CHANGE"] = "onStateChange";
|
|
606
|
+
WatchedQueryListenerEvent["SETTINGS_WILL_UPDATE"] = "settingsWillUpdate";
|
|
607
|
+
WatchedQueryListenerEvent["CLOSED"] = "closed";
|
|
608
|
+
})(exports.WatchedQueryListenerEvent || (exports.WatchedQueryListenerEvent = {}));
|
|
609
|
+
const DEFAULT_WATCH_THROTTLE_MS = 30;
|
|
610
|
+
const DEFAULT_WATCH_QUERY_OPTIONS = {
|
|
611
|
+
throttleMs: DEFAULT_WATCH_THROTTLE_MS,
|
|
612
|
+
reportFetching: true
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Orchestrates attachment synchronization between local and remote storage.
|
|
617
|
+
* Handles uploads, downloads, deletions, and state transitions.
|
|
618
|
+
*
|
|
619
|
+
* @internal
|
|
620
|
+
*/
|
|
621
|
+
class SyncingService {
|
|
622
|
+
attachmentService;
|
|
623
|
+
localStorage;
|
|
624
|
+
remoteStorage;
|
|
625
|
+
logger;
|
|
626
|
+
errorHandler;
|
|
627
|
+
constructor(attachmentService, localStorage, remoteStorage, logger, errorHandler) {
|
|
628
|
+
this.attachmentService = attachmentService;
|
|
629
|
+
this.localStorage = localStorage;
|
|
630
|
+
this.remoteStorage = remoteStorage;
|
|
631
|
+
this.logger = logger;
|
|
632
|
+
this.errorHandler = errorHandler;
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Processes attachments based on their state (upload, download, or delete).
|
|
636
|
+
* All updates are saved in a single batch after processing.
|
|
637
|
+
*
|
|
638
|
+
* @param attachments - Array of attachment records to process
|
|
639
|
+
* @param context - Attachment context for database operations
|
|
640
|
+
* @returns Promise that resolves when all attachments have been processed and saved
|
|
641
|
+
*/
|
|
642
|
+
async processAttachments(attachments, context) {
|
|
643
|
+
const updatedAttachments = [];
|
|
644
|
+
for (const attachment of attachments) {
|
|
645
|
+
switch (attachment.state) {
|
|
646
|
+
case exports.AttachmentState.QUEUED_UPLOAD:
|
|
647
|
+
const uploaded = await this.uploadAttachment(attachment);
|
|
648
|
+
updatedAttachments.push(uploaded);
|
|
649
|
+
break;
|
|
650
|
+
case exports.AttachmentState.QUEUED_DOWNLOAD:
|
|
651
|
+
const downloaded = await this.downloadAttachment(attachment);
|
|
652
|
+
updatedAttachments.push(downloaded);
|
|
653
|
+
break;
|
|
654
|
+
case exports.AttachmentState.QUEUED_DELETE:
|
|
655
|
+
const deleted = await this.deleteAttachment(attachment);
|
|
656
|
+
updatedAttachments.push(deleted);
|
|
657
|
+
break;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
await context.saveAttachments(updatedAttachments);
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Uploads an attachment from local storage to remote storage.
|
|
664
|
+
* On success, marks as SYNCED. On failure, defers to error handler or archives.
|
|
665
|
+
*
|
|
666
|
+
* @param attachment - The attachment record to upload
|
|
667
|
+
* @returns Updated attachment record with new state
|
|
668
|
+
* @throws Error if the attachment has no localUri
|
|
669
|
+
*/
|
|
670
|
+
async uploadAttachment(attachment) {
|
|
671
|
+
this.logger.info(`Uploading attachment ${attachment.filename}`);
|
|
672
|
+
try {
|
|
673
|
+
if (attachment.localUri == null) {
|
|
674
|
+
throw new Error(`No localUri for attachment ${attachment.id}`);
|
|
675
|
+
}
|
|
676
|
+
const fileBlob = await this.localStorage.readFile(attachment.localUri);
|
|
677
|
+
await this.remoteStorage.uploadFile(fileBlob, attachment);
|
|
678
|
+
return {
|
|
679
|
+
...attachment,
|
|
680
|
+
state: exports.AttachmentState.SYNCED,
|
|
681
|
+
hasSynced: true
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
catch (error) {
|
|
685
|
+
const shouldRetry = (await this.errorHandler?.onUploadError(attachment, error)) ?? true;
|
|
686
|
+
if (!shouldRetry) {
|
|
687
|
+
return {
|
|
688
|
+
...attachment,
|
|
689
|
+
state: exports.AttachmentState.ARCHIVED
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
return attachment;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Downloads an attachment from remote storage to local storage.
|
|
697
|
+
* Retrieves the file, converts to base64, and saves locally.
|
|
698
|
+
* On success, marks as SYNCED. On failure, defers to error handler or archives.
|
|
699
|
+
*
|
|
700
|
+
* @param attachment - The attachment record to download
|
|
701
|
+
* @returns Updated attachment record with local URI and new state
|
|
702
|
+
*/
|
|
703
|
+
async downloadAttachment(attachment) {
|
|
704
|
+
this.logger.info(`Downloading attachment ${attachment.filename}`);
|
|
705
|
+
try {
|
|
706
|
+
const fileData = await this.remoteStorage.downloadFile(attachment);
|
|
707
|
+
const localUri = this.localStorage.getLocalUri(attachment.filename);
|
|
708
|
+
await this.localStorage.saveFile(localUri, fileData);
|
|
709
|
+
return {
|
|
710
|
+
...attachment,
|
|
711
|
+
state: exports.AttachmentState.SYNCED,
|
|
712
|
+
localUri: localUri,
|
|
713
|
+
hasSynced: true
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
catch (error) {
|
|
717
|
+
const shouldRetry = (await this.errorHandler?.onDownloadError(attachment, error)) ?? true;
|
|
718
|
+
if (!shouldRetry) {
|
|
719
|
+
return {
|
|
720
|
+
...attachment,
|
|
721
|
+
state: exports.AttachmentState.ARCHIVED
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
return attachment;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* Deletes an attachment from both remote and local storage.
|
|
729
|
+
* Removes the remote file, local file (if exists), and the attachment record.
|
|
730
|
+
* On failure, defers to error handler or archives.
|
|
731
|
+
*
|
|
732
|
+
* @param attachment - The attachment record to delete
|
|
733
|
+
* @returns Updated attachment record
|
|
734
|
+
*/
|
|
735
|
+
async deleteAttachment(attachment) {
|
|
736
|
+
try {
|
|
737
|
+
await this.remoteStorage.deleteFile(attachment);
|
|
738
|
+
if (attachment.localUri) {
|
|
739
|
+
await this.localStorage.deleteFile(attachment.localUri);
|
|
740
|
+
}
|
|
741
|
+
await this.attachmentService.withContext(async (ctx) => {
|
|
742
|
+
await ctx.deleteAttachment(attachment.id);
|
|
743
|
+
});
|
|
744
|
+
return {
|
|
745
|
+
...attachment,
|
|
746
|
+
state: exports.AttachmentState.ARCHIVED
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
catch (error) {
|
|
750
|
+
const shouldRetry = (await this.errorHandler?.onDeleteError(attachment, error)) ?? true;
|
|
751
|
+
if (!shouldRetry) {
|
|
752
|
+
return {
|
|
753
|
+
...attachment,
|
|
754
|
+
state: exports.AttachmentState.ARCHIVED
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
return attachment;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* Performs cleanup of archived attachments by removing their local files and records.
|
|
762
|
+
* Errors during local file deletion are logged but do not prevent record deletion.
|
|
763
|
+
*/
|
|
764
|
+
async deleteArchivedAttachments(context) {
|
|
765
|
+
return await context.deleteArchivedAttachments(async (archivedAttachments) => {
|
|
766
|
+
for (const attachment of archivedAttachments) {
|
|
767
|
+
if (attachment.localUri) {
|
|
768
|
+
try {
|
|
769
|
+
await this.localStorage.deleteFile(attachment.localUri);
|
|
770
|
+
}
|
|
771
|
+
catch (error) {
|
|
772
|
+
this.logger.error('Error deleting local file for archived attachment', error);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* Wrapper for async-mutex runExclusive, which allows for a timeout on each exclusive lock.
|
|
782
|
+
*/
|
|
783
|
+
async function mutexRunExclusive(mutex, callback, options) {
|
|
784
|
+
return new Promise((resolve, reject) => {
|
|
785
|
+
mutex.runExclusive(async () => {
|
|
786
|
+
try {
|
|
787
|
+
resolve(await callback());
|
|
788
|
+
}
|
|
789
|
+
catch (ex) {
|
|
790
|
+
reject(ex);
|
|
791
|
+
}
|
|
792
|
+
});
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* Service for querying and watching attachment records in the database.
|
|
798
|
+
*
|
|
799
|
+
* @internal
|
|
800
|
+
*/
|
|
801
|
+
class AttachmentService {
|
|
802
|
+
db;
|
|
803
|
+
logger;
|
|
804
|
+
tableName;
|
|
805
|
+
mutex = new asyncMutex.Mutex();
|
|
806
|
+
context;
|
|
807
|
+
constructor(db, logger, tableName = 'attachments', archivedCacheLimit = 100) {
|
|
808
|
+
this.db = db;
|
|
809
|
+
this.logger = logger;
|
|
810
|
+
this.tableName = tableName;
|
|
811
|
+
this.context = new AttachmentContext(db, tableName, logger, archivedCacheLimit);
|
|
812
|
+
}
|
|
813
|
+
/**
|
|
814
|
+
* Creates a differential watch query for active attachments requiring synchronization.
|
|
815
|
+
* @returns Watch query that emits changes for queued uploads, downloads, and deletes
|
|
816
|
+
*/
|
|
817
|
+
watchActiveAttachments({ throttleMs } = {}) {
|
|
818
|
+
this.logger.info('Watching active attachments...');
|
|
819
|
+
const watch = this.db
|
|
820
|
+
.query({
|
|
821
|
+
sql: /* sql */ `
|
|
822
|
+
SELECT
|
|
823
|
+
*
|
|
824
|
+
FROM
|
|
825
|
+
${this.tableName}
|
|
826
|
+
WHERE
|
|
827
|
+
state = ?
|
|
828
|
+
OR state = ?
|
|
829
|
+
OR state = ?
|
|
830
|
+
ORDER BY
|
|
831
|
+
timestamp ASC
|
|
832
|
+
`,
|
|
833
|
+
parameters: [exports.AttachmentState.QUEUED_UPLOAD, exports.AttachmentState.QUEUED_DOWNLOAD, exports.AttachmentState.QUEUED_DELETE]
|
|
834
|
+
})
|
|
835
|
+
.differentialWatch({ throttleMs });
|
|
836
|
+
return watch;
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Executes a callback with exclusive access to the attachment context.
|
|
840
|
+
*/
|
|
841
|
+
async withContext(callback) {
|
|
842
|
+
return mutexRunExclusive(this.mutex, async () => {
|
|
843
|
+
return callback(this.context);
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* AttachmentQueue manages the lifecycle and synchronization of attachments
|
|
850
|
+
* between local and remote storage.
|
|
851
|
+
* Provides automatic synchronization, upload/download queuing, attachment monitoring,
|
|
852
|
+
* verification and repair of local files, and cleanup of archived attachments.
|
|
853
|
+
*
|
|
854
|
+
* @experimental
|
|
855
|
+
* @alpha This is currently experimental and may change without a major version bump.
|
|
856
|
+
*/
|
|
857
|
+
class AttachmentQueue {
|
|
858
|
+
/** Timer for periodic synchronization operations */
|
|
859
|
+
periodicSyncTimer;
|
|
860
|
+
/** Service for synchronizing attachments between local and remote storage */
|
|
861
|
+
syncingService;
|
|
862
|
+
/** Adapter for local file storage operations */
|
|
863
|
+
localStorage;
|
|
864
|
+
/** Adapter for remote file storage operations */
|
|
865
|
+
remoteStorage;
|
|
866
|
+
/**
|
|
867
|
+
* Callback function to watch for changes in attachment references in your data model.
|
|
868
|
+
*
|
|
869
|
+
* This should be implemented by the user of AttachmentQueue to monitor changes in your application's
|
|
870
|
+
* data that reference attachments. When attachments are added, removed, or modified,
|
|
871
|
+
* this callback should trigger the onUpdate function with the current set of attachments.
|
|
872
|
+
*/
|
|
873
|
+
watchAttachments;
|
|
874
|
+
/** Name of the database table storing attachment records */
|
|
875
|
+
tableName;
|
|
876
|
+
/** Logger instance for diagnostic information */
|
|
877
|
+
logger;
|
|
878
|
+
/** Interval in milliseconds between periodic sync operations. Default: 30000 (30 seconds) */
|
|
879
|
+
syncIntervalMs = 30 * 1000;
|
|
880
|
+
/** Duration in milliseconds to throttle sync operations */
|
|
881
|
+
syncThrottleDuration;
|
|
882
|
+
/** Whether to automatically download remote attachments. Default: true */
|
|
883
|
+
downloadAttachments = true;
|
|
884
|
+
/** Maximum number of archived attachments to keep before cleanup. Default: 100 */
|
|
885
|
+
archivedCacheLimit;
|
|
886
|
+
/** Service for managing attachment-related database operations */
|
|
887
|
+
attachmentService;
|
|
888
|
+
/** PowerSync database instance */
|
|
889
|
+
db;
|
|
890
|
+
/** Cleanup function for status change listener */
|
|
891
|
+
statusListenerDispose;
|
|
892
|
+
watchActiveAttachments;
|
|
893
|
+
watchAttachmentsAbortController;
|
|
894
|
+
/**
|
|
895
|
+
* Creates a new AttachmentQueue instance.
|
|
896
|
+
*
|
|
897
|
+
* @param options - Configuration options
|
|
898
|
+
* @param options.db - PowerSync database instance
|
|
899
|
+
* @param options.remoteStorage - Remote storage adapter for upload/download operations
|
|
900
|
+
* @param options.localStorage - Local storage adapter for file persistence
|
|
901
|
+
* @param options.watchAttachments - Callback for monitoring attachment changes in your data model
|
|
902
|
+
* @param options.tableName - Name of the table to store attachment records. Default: 'ps_attachment_queue'
|
|
903
|
+
* @param options.logger - Logger instance. Defaults to db.logger
|
|
904
|
+
* @param options.syncIntervalMs - Interval between automatic syncs in milliseconds. Default: 30000
|
|
905
|
+
* @param options.syncThrottleDuration - Throttle duration for sync operations in milliseconds. Default: 1000
|
|
906
|
+
* @param options.downloadAttachments - Whether to automatically download remote attachments. Default: true
|
|
907
|
+
* @param options.archivedCacheLimit - Maximum archived attachments before cleanup. Default: 100
|
|
908
|
+
*/
|
|
909
|
+
constructor({ db, localStorage, remoteStorage, watchAttachments, logger, tableName = ATTACHMENT_TABLE, syncIntervalMs = 30 * 1000, syncThrottleDuration = DEFAULT_WATCH_THROTTLE_MS, downloadAttachments = true, archivedCacheLimit = 100, errorHandler }) {
|
|
910
|
+
this.db = db;
|
|
911
|
+
this.remoteStorage = remoteStorage;
|
|
912
|
+
this.localStorage = localStorage;
|
|
913
|
+
this.watchAttachments = watchAttachments;
|
|
914
|
+
this.tableName = tableName;
|
|
915
|
+
this.syncIntervalMs = syncIntervalMs;
|
|
916
|
+
this.syncThrottleDuration = syncThrottleDuration;
|
|
917
|
+
this.archivedCacheLimit = archivedCacheLimit;
|
|
918
|
+
this.downloadAttachments = downloadAttachments;
|
|
919
|
+
this.logger = logger ?? db.logger;
|
|
920
|
+
this.attachmentService = new AttachmentService(db, this.logger, tableName, archivedCacheLimit);
|
|
921
|
+
this.syncingService = new SyncingService(this.attachmentService, localStorage, remoteStorage, this.logger, errorHandler);
|
|
922
|
+
}
|
|
923
|
+
/**
|
|
924
|
+
* Generates a new attachment ID using a SQLite UUID function.
|
|
925
|
+
*
|
|
926
|
+
* @returns Promise resolving to the new attachment ID
|
|
927
|
+
*/
|
|
928
|
+
async generateAttachmentId() {
|
|
929
|
+
return this.db.get('SELECT uuid() as id').then((row) => row.id);
|
|
930
|
+
}
|
|
931
|
+
/**
|
|
932
|
+
* Starts the attachment synchronization process.
|
|
933
|
+
*
|
|
934
|
+
* This method:
|
|
935
|
+
* - Stops any existing sync operations
|
|
936
|
+
* - Sets up periodic synchronization based on syncIntervalMs
|
|
937
|
+
* - Registers listeners for active attachment changes
|
|
938
|
+
* - Processes watched attachments to queue uploads/downloads
|
|
939
|
+
* - Handles state transitions for archived and new attachments
|
|
940
|
+
*/
|
|
941
|
+
async startSync() {
|
|
942
|
+
await this.stopSync();
|
|
943
|
+
this.watchActiveAttachments = this.attachmentService.watchActiveAttachments({
|
|
944
|
+
throttleMs: this.syncThrottleDuration
|
|
945
|
+
});
|
|
946
|
+
// immediately invoke the sync storage to initialize local storage
|
|
947
|
+
await this.localStorage.initialize();
|
|
948
|
+
await this.verifyAttachments();
|
|
949
|
+
// Sync storage periodically
|
|
950
|
+
this.periodicSyncTimer = setInterval(async () => {
|
|
951
|
+
await this.syncStorage();
|
|
952
|
+
}, this.syncIntervalMs);
|
|
953
|
+
// Sync storage when there is a change in active attachments
|
|
954
|
+
this.watchActiveAttachments.registerListener({
|
|
955
|
+
onDiff: async () => {
|
|
956
|
+
await this.syncStorage();
|
|
957
|
+
}
|
|
958
|
+
});
|
|
959
|
+
this.statusListenerDispose = this.db.registerListener({
|
|
960
|
+
statusChanged: (status) => {
|
|
961
|
+
if (status.connected) {
|
|
962
|
+
// Device came online, process attachments immediately
|
|
963
|
+
this.syncStorage().catch((error) => {
|
|
964
|
+
this.logger.error('Error syncing storage on connection:', error);
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
});
|
|
969
|
+
this.watchAttachmentsAbortController = new AbortController();
|
|
970
|
+
const signal = this.watchAttachmentsAbortController.signal;
|
|
971
|
+
// Process attachments when there is a change in watched attachments
|
|
972
|
+
this.watchAttachments(async (watchedAttachments) => {
|
|
973
|
+
// Skip processing if sync has been stopped
|
|
974
|
+
if (signal.aborted) {
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
await this.attachmentService.withContext(async (ctx) => {
|
|
978
|
+
// Need to get all the attachments which are tracked in the DB.
|
|
979
|
+
// We might need to restore an archived attachment.
|
|
980
|
+
const currentAttachments = await ctx.getAttachments();
|
|
981
|
+
const attachmentUpdates = [];
|
|
982
|
+
for (const watchedAttachment of watchedAttachments) {
|
|
983
|
+
const existingQueueItem = currentAttachments.find((a) => a.id === watchedAttachment.id);
|
|
984
|
+
if (!existingQueueItem) {
|
|
985
|
+
// Item is watched but not in the queue yet. Need to add it.
|
|
986
|
+
if (!this.downloadAttachments) {
|
|
987
|
+
continue;
|
|
988
|
+
}
|
|
989
|
+
const filename = watchedAttachment.filename ?? `${watchedAttachment.id}.${watchedAttachment.fileExtension}`;
|
|
990
|
+
attachmentUpdates.push({
|
|
991
|
+
id: watchedAttachment.id,
|
|
992
|
+
filename,
|
|
993
|
+
state: exports.AttachmentState.QUEUED_DOWNLOAD,
|
|
994
|
+
hasSynced: false,
|
|
995
|
+
metaData: watchedAttachment.metaData,
|
|
996
|
+
timestamp: new Date().getTime()
|
|
997
|
+
});
|
|
998
|
+
continue;
|
|
999
|
+
}
|
|
1000
|
+
if (existingQueueItem.state === exports.AttachmentState.ARCHIVED) {
|
|
1001
|
+
// The attachment is present again. Need to queue it for sync.
|
|
1002
|
+
// We might be able to optimize this in future
|
|
1003
|
+
if (existingQueueItem.hasSynced === true) {
|
|
1004
|
+
// No remote action required, we can restore the record (avoids deletion)
|
|
1005
|
+
attachmentUpdates.push({
|
|
1006
|
+
...existingQueueItem,
|
|
1007
|
+
state: exports.AttachmentState.SYNCED
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
else {
|
|
1011
|
+
// The localURI should be set if the record was meant to be uploaded
|
|
1012
|
+
// and hasSynced is false then
|
|
1013
|
+
// it must be an upload operation
|
|
1014
|
+
const newState = existingQueueItem.localUri == null ? exports.AttachmentState.QUEUED_DOWNLOAD : exports.AttachmentState.QUEUED_UPLOAD;
|
|
1015
|
+
attachmentUpdates.push({
|
|
1016
|
+
...existingQueueItem,
|
|
1017
|
+
state: newState
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
for (const attachment of currentAttachments) {
|
|
1023
|
+
const notInWatchedItems = watchedAttachments.find((i) => i.id === attachment.id) == null;
|
|
1024
|
+
if (notInWatchedItems) {
|
|
1025
|
+
switch (attachment.state) {
|
|
1026
|
+
case exports.AttachmentState.QUEUED_DELETE:
|
|
1027
|
+
case exports.AttachmentState.QUEUED_UPLOAD:
|
|
1028
|
+
// Only archive if it has synced
|
|
1029
|
+
if (attachment.hasSynced === true) {
|
|
1030
|
+
attachmentUpdates.push({
|
|
1031
|
+
...attachment,
|
|
1032
|
+
state: exports.AttachmentState.ARCHIVED
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
break;
|
|
1036
|
+
default:
|
|
1037
|
+
// Archive other states such as QUEUED_DOWNLOAD
|
|
1038
|
+
attachmentUpdates.push({
|
|
1039
|
+
...attachment,
|
|
1040
|
+
state: exports.AttachmentState.ARCHIVED
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
if (attachmentUpdates.length > 0) {
|
|
1046
|
+
await ctx.saveAttachments(attachmentUpdates);
|
|
1047
|
+
}
|
|
1048
|
+
});
|
|
1049
|
+
}, signal);
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* Synchronizes all active attachments between local and remote storage.
|
|
1053
|
+
*
|
|
1054
|
+
* This is called automatically at regular intervals when sync is started,
|
|
1055
|
+
* but can also be called manually to trigger an immediate sync.
|
|
1056
|
+
*/
|
|
1057
|
+
async syncStorage() {
|
|
1058
|
+
await this.attachmentService.withContext(async (ctx) => {
|
|
1059
|
+
const activeAttachments = await ctx.getActiveAttachments();
|
|
1060
|
+
await this.localStorage.initialize();
|
|
1061
|
+
await this.syncingService.processAttachments(activeAttachments, ctx);
|
|
1062
|
+
await this.syncingService.deleteArchivedAttachments(ctx);
|
|
1063
|
+
});
|
|
1064
|
+
}
|
|
1065
|
+
/**
|
|
1066
|
+
* Stops the attachment synchronization process.
|
|
1067
|
+
*
|
|
1068
|
+
* Clears the periodic sync timer and closes all active attachment watchers.
|
|
1069
|
+
*/
|
|
1070
|
+
async stopSync() {
|
|
1071
|
+
clearInterval(this.periodicSyncTimer);
|
|
1072
|
+
this.periodicSyncTimer = undefined;
|
|
1073
|
+
if (this.watchActiveAttachments)
|
|
1074
|
+
await this.watchActiveAttachments.close();
|
|
1075
|
+
if (this.watchAttachmentsAbortController) {
|
|
1076
|
+
this.watchAttachmentsAbortController.abort();
|
|
1077
|
+
}
|
|
1078
|
+
if (this.statusListenerDispose) {
|
|
1079
|
+
this.statusListenerDispose();
|
|
1080
|
+
this.statusListenerDispose = undefined;
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
/**
|
|
1084
|
+
* Saves a file to local storage and queues it for upload to remote storage.
|
|
1085
|
+
*
|
|
1086
|
+
* @param options - File save options
|
|
1087
|
+
* @param options.data - The file data as ArrayBuffer, Blob, or base64 string
|
|
1088
|
+
* @param options.fileExtension - File extension (e.g., 'jpg', 'pdf')
|
|
1089
|
+
* @param options.mediaType - MIME type of the file (e.g., 'image/jpeg')
|
|
1090
|
+
* @param options.metaData - Optional metadata to associate with the attachment
|
|
1091
|
+
* @param options.id - Optional custom ID. If not provided, a UUID will be generated
|
|
1092
|
+
* @param options.updateHook - Optional callback to execute additional database operations
|
|
1093
|
+
* within the same transaction as the attachment creation
|
|
1094
|
+
* @returns Promise resolving to the created attachment record
|
|
1095
|
+
*/
|
|
1096
|
+
async saveFile({ data, fileExtension, mediaType, metaData, id, updateHook }) {
|
|
1097
|
+
const resolvedId = id ?? (await this.generateAttachmentId());
|
|
1098
|
+
const filename = `${resolvedId}.${fileExtension}`;
|
|
1099
|
+
const localUri = this.localStorage.getLocalUri(filename);
|
|
1100
|
+
const size = await this.localStorage.saveFile(localUri, data);
|
|
1101
|
+
const attachment = {
|
|
1102
|
+
id: resolvedId,
|
|
1103
|
+
filename,
|
|
1104
|
+
mediaType,
|
|
1105
|
+
localUri,
|
|
1106
|
+
state: exports.AttachmentState.QUEUED_UPLOAD,
|
|
1107
|
+
hasSynced: false,
|
|
1108
|
+
size,
|
|
1109
|
+
timestamp: new Date().getTime(),
|
|
1110
|
+
metaData
|
|
1111
|
+
};
|
|
1112
|
+
await this.attachmentService.withContext(async (ctx) => {
|
|
1113
|
+
await ctx.db.writeTransaction(async (tx) => {
|
|
1114
|
+
await updateHook?.(tx, attachment);
|
|
1115
|
+
await ctx.upsertAttachment(attachment, tx);
|
|
1116
|
+
});
|
|
1117
|
+
});
|
|
1118
|
+
return attachment;
|
|
1119
|
+
}
|
|
1120
|
+
async deleteFile({ id, updateHook }) {
|
|
1121
|
+
await this.attachmentService.withContext(async (ctx) => {
|
|
1122
|
+
const attachment = await ctx.getAttachment(id);
|
|
1123
|
+
if (!attachment) {
|
|
1124
|
+
throw new Error(`Attachment with id ${id} not found`);
|
|
1125
|
+
}
|
|
1126
|
+
await ctx.db.writeTransaction(async (tx) => {
|
|
1127
|
+
await updateHook?.(tx, attachment);
|
|
1128
|
+
await ctx.upsertAttachment({
|
|
1129
|
+
...attachment,
|
|
1130
|
+
state: exports.AttachmentState.QUEUED_DELETE,
|
|
1131
|
+
hasSynced: false
|
|
1132
|
+
}, tx);
|
|
1133
|
+
});
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
async expireCache() {
|
|
1137
|
+
let isDone = false;
|
|
1138
|
+
while (!isDone) {
|
|
1139
|
+
await this.attachmentService.withContext(async (ctx) => {
|
|
1140
|
+
isDone = await this.syncingService.deleteArchivedAttachments(ctx);
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
async clearQueue() {
|
|
1145
|
+
await this.attachmentService.withContext(async (ctx) => {
|
|
1146
|
+
await ctx.clearQueue();
|
|
1147
|
+
});
|
|
1148
|
+
await this.localStorage.clear();
|
|
1149
|
+
}
|
|
1150
|
+
/**
|
|
1151
|
+
* Verifies the integrity of all attachment records and repairs inconsistencies.
|
|
1152
|
+
*
|
|
1153
|
+
* This method checks each attachment record against the local filesystem and:
|
|
1154
|
+
* - Updates localUri if the file exists at a different path
|
|
1155
|
+
* - Archives attachments with missing local files that haven't been uploaded
|
|
1156
|
+
* - Requeues synced attachments for download if their local files are missing
|
|
1157
|
+
*/
|
|
1158
|
+
async verifyAttachments() {
|
|
1159
|
+
await this.attachmentService.withContext(async (ctx) => {
|
|
1160
|
+
const attachments = await ctx.getAttachments();
|
|
1161
|
+
const updates = [];
|
|
1162
|
+
for (const attachment of attachments) {
|
|
1163
|
+
if (attachment.localUri == null) {
|
|
1164
|
+
continue;
|
|
1165
|
+
}
|
|
1166
|
+
const exists = await this.localStorage.fileExists(attachment.localUri);
|
|
1167
|
+
if (exists) {
|
|
1168
|
+
// The file exists, this is correct
|
|
1169
|
+
continue;
|
|
1170
|
+
}
|
|
1171
|
+
const newLocalUri = this.localStorage.getLocalUri(attachment.filename);
|
|
1172
|
+
const newExists = await this.localStorage.fileExists(newLocalUri);
|
|
1173
|
+
if (newExists) {
|
|
1174
|
+
// The file exists locally but the localUri is broken, we update it.
|
|
1175
|
+
updates.push({
|
|
1176
|
+
...attachment,
|
|
1177
|
+
localUri: newLocalUri
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
else {
|
|
1181
|
+
// the file doesn't exist locally.
|
|
1182
|
+
if (attachment.state === exports.AttachmentState.SYNCED) {
|
|
1183
|
+
// the file has been successfully synced to remote storage but is missing
|
|
1184
|
+
// we download it again
|
|
1185
|
+
updates.push({
|
|
1186
|
+
...attachment,
|
|
1187
|
+
state: exports.AttachmentState.QUEUED_DOWNLOAD,
|
|
1188
|
+
localUri: undefined
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
else {
|
|
1192
|
+
// the file wasn't successfully synced to remote storage, we archive it
|
|
1193
|
+
updates.push({
|
|
1194
|
+
...attachment,
|
|
1195
|
+
state: exports.AttachmentState.ARCHIVED,
|
|
1196
|
+
localUri: undefined // Clears the value
|
|
1197
|
+
});
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
await ctx.saveAttachments(updates);
|
|
1202
|
+
});
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
exports.EncodingType = void 0;
|
|
1207
|
+
(function (EncodingType) {
|
|
1208
|
+
EncodingType["UTF8"] = "utf8";
|
|
1209
|
+
EncodingType["Base64"] = "base64";
|
|
1210
|
+
})(exports.EncodingType || (exports.EncodingType = {}));
|
|
1211
|
+
|
|
7
1212
|
function getDefaultExportFromCjs (x) {
|
|
8
1213
|
return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x;
|
|
9
1214
|
}
|
|
@@ -1167,20 +2372,6 @@ class MetaBaseObserver extends BaseObserver {
|
|
|
1167
2372
|
}
|
|
1168
2373
|
}
|
|
1169
2374
|
|
|
1170
|
-
exports.WatchedQueryListenerEvent = void 0;
|
|
1171
|
-
(function (WatchedQueryListenerEvent) {
|
|
1172
|
-
WatchedQueryListenerEvent["ON_DATA"] = "onData";
|
|
1173
|
-
WatchedQueryListenerEvent["ON_ERROR"] = "onError";
|
|
1174
|
-
WatchedQueryListenerEvent["ON_STATE_CHANGE"] = "onStateChange";
|
|
1175
|
-
WatchedQueryListenerEvent["SETTINGS_WILL_UPDATE"] = "settingsWillUpdate";
|
|
1176
|
-
WatchedQueryListenerEvent["CLOSED"] = "closed";
|
|
1177
|
-
})(exports.WatchedQueryListenerEvent || (exports.WatchedQueryListenerEvent = {}));
|
|
1178
|
-
const DEFAULT_WATCH_THROTTLE_MS = 30;
|
|
1179
|
-
const DEFAULT_WATCH_QUERY_OPTIONS = {
|
|
1180
|
-
throttleMs: DEFAULT_WATCH_THROTTLE_MS,
|
|
1181
|
-
reportFetching: true
|
|
1182
|
-
};
|
|
1183
|
-
|
|
1184
2375
|
/**
|
|
1185
2376
|
* Performs underlying watching and yields a stream of results.
|
|
1186
2377
|
* @internal
|
|
@@ -6722,7 +7913,7 @@ function requireDist () {
|
|
|
6722
7913
|
|
|
6723
7914
|
var distExports = requireDist();
|
|
6724
7915
|
|
|
6725
|
-
var version = "1.
|
|
7916
|
+
var version = "1.46.0";
|
|
6726
7917
|
var PACKAGE = {
|
|
6727
7918
|
version: version};
|
|
6728
7919
|
|
|
@@ -7624,18 +8815,17 @@ exports.SyncClientImplementation = void 0;
|
|
|
7624
8815
|
*
|
|
7625
8816
|
* This is the default option.
|
|
7626
8817
|
*
|
|
7627
|
-
* @deprecated
|
|
7628
|
-
*
|
|
7629
|
-
*
|
|
8818
|
+
* @deprecated We recommend the {@link RUST} client implementation for all apps. If you have issues with
|
|
8819
|
+
* the Rust client, please file an issue or reach out to us. The JavaScript client will be removed in a future
|
|
8820
|
+
* version of the PowerSync SDK.
|
|
7630
8821
|
*/
|
|
7631
8822
|
SyncClientImplementation["JAVASCRIPT"] = "js";
|
|
7632
8823
|
/**
|
|
7633
8824
|
* This implementation offloads the sync line decoding and handling into the PowerSync
|
|
7634
8825
|
* core extension.
|
|
7635
8826
|
*
|
|
7636
|
-
* @
|
|
7637
|
-
*
|
|
7638
|
-
* it has seen less real-world testing and is marked as __experimental__ at the moment.
|
|
8827
|
+
* This option is more performant than the {@link JAVASCRIPT} client, enabled by default and the
|
|
8828
|
+
* recommended client implementation for all apps.
|
|
7639
8829
|
*
|
|
7640
8830
|
* ## Compatibility warning
|
|
7641
8831
|
*
|
|
@@ -7653,13 +8843,9 @@ exports.SyncClientImplementation = void 0;
|
|
|
7653
8843
|
SyncClientImplementation["RUST"] = "rust";
|
|
7654
8844
|
})(exports.SyncClientImplementation || (exports.SyncClientImplementation = {}));
|
|
7655
8845
|
/**
|
|
7656
|
-
* The default {@link SyncClientImplementation} to use.
|
|
7657
|
-
*
|
|
7658
|
-
* Please use this field instead of {@link SyncClientImplementation.JAVASCRIPT} directly. A future version
|
|
7659
|
-
* of the PowerSync SDK will enable {@link SyncClientImplementation.RUST} by default and remove the JavaScript
|
|
7660
|
-
* option.
|
|
8846
|
+
* The default {@link SyncClientImplementation} to use, {@link SyncClientImplementation.RUST}.
|
|
7661
8847
|
*/
|
|
7662
|
-
const DEFAULT_SYNC_CLIENT_IMPLEMENTATION = exports.SyncClientImplementation.
|
|
8848
|
+
const DEFAULT_SYNC_CLIENT_IMPLEMENTATION = exports.SyncClientImplementation.RUST;
|
|
7663
8849
|
const DEFAULT_CRUD_UPLOAD_THROTTLE_MS = 1000;
|
|
7664
8850
|
const DEFAULT_RETRY_DELAY_MS = 5000;
|
|
7665
8851
|
const DEFAULT_STREAMING_SYNC_OPTIONS = {
|
|
@@ -8069,6 +9255,9 @@ The next upload iteration will be delayed.`);
|
|
|
8069
9255
|
if (rawTables != null && rawTables.length) {
|
|
8070
9256
|
this.logger.warn('Raw tables require the Rust-based sync client. The JS client will ignore them.');
|
|
8071
9257
|
}
|
|
9258
|
+
if (this.activeStreams.length) {
|
|
9259
|
+
this.logger.error('Sync streams require `clientImplementation: SyncClientImplementation.RUST` when connecting.');
|
|
9260
|
+
}
|
|
8072
9261
|
this.logger.debug('Streaming sync iteration started');
|
|
8073
9262
|
this.options.adapter.startSession();
|
|
8074
9263
|
let [req, bucketMap] = await this.collectLocalBucketState();
|
|
@@ -8584,6 +9773,27 @@ The next upload iteration will be delayed.`);
|
|
|
8584
9773
|
}
|
|
8585
9774
|
}
|
|
8586
9775
|
|
|
9776
|
+
const CLAIM_STORE = new Map();
|
|
9777
|
+
/**
|
|
9778
|
+
* @internal
|
|
9779
|
+
* @experimental
|
|
9780
|
+
*/
|
|
9781
|
+
const MEMORY_TRIGGER_CLAIM_MANAGER = {
|
|
9782
|
+
async obtainClaim(identifier) {
|
|
9783
|
+
if (CLAIM_STORE.has(identifier)) {
|
|
9784
|
+
throw new Error(`A claim is already present for ${identifier}`);
|
|
9785
|
+
}
|
|
9786
|
+
const release = async () => {
|
|
9787
|
+
CLAIM_STORE.delete(identifier);
|
|
9788
|
+
};
|
|
9789
|
+
CLAIM_STORE.set(identifier, release);
|
|
9790
|
+
return release;
|
|
9791
|
+
},
|
|
9792
|
+
async checkClaim(identifier) {
|
|
9793
|
+
return CLAIM_STORE.has(identifier);
|
|
9794
|
+
}
|
|
9795
|
+
};
|
|
9796
|
+
|
|
8587
9797
|
/**
|
|
8588
9798
|
* SQLite operations to track changes for with {@link TriggerManager}
|
|
8589
9799
|
* @experimental
|
|
@@ -8595,9 +9805,20 @@ exports.DiffTriggerOperation = void 0;
|
|
|
8595
9805
|
DiffTriggerOperation["DELETE"] = "DELETE";
|
|
8596
9806
|
})(exports.DiffTriggerOperation || (exports.DiffTriggerOperation = {}));
|
|
8597
9807
|
|
|
9808
|
+
const DEFAULT_TRIGGER_MANAGER_CONFIGURATION = {
|
|
9809
|
+
useStorageByDefault: false
|
|
9810
|
+
};
|
|
9811
|
+
const TRIGGER_CLEANUP_INTERVAL_MS = 120_000; // 2 minutes
|
|
9812
|
+
/**
|
|
9813
|
+
* @internal
|
|
9814
|
+
* @experimental
|
|
9815
|
+
*/
|
|
8598
9816
|
class TriggerManagerImpl {
|
|
8599
9817
|
options;
|
|
8600
9818
|
schema;
|
|
9819
|
+
defaultConfig;
|
|
9820
|
+
cleanupTimeout;
|
|
9821
|
+
isDisposed;
|
|
8601
9822
|
constructor(options) {
|
|
8602
9823
|
this.options = options;
|
|
8603
9824
|
this.schema = options.schema;
|
|
@@ -8606,6 +9827,33 @@ class TriggerManagerImpl {
|
|
|
8606
9827
|
this.schema = schema;
|
|
8607
9828
|
}
|
|
8608
9829
|
});
|
|
9830
|
+
this.isDisposed = false;
|
|
9831
|
+
/**
|
|
9832
|
+
* Configure a cleanup to run on an interval.
|
|
9833
|
+
* The interval is configured using setTimeout to take the async
|
|
9834
|
+
* execution time of the callback into account.
|
|
9835
|
+
*/
|
|
9836
|
+
this.defaultConfig = DEFAULT_TRIGGER_MANAGER_CONFIGURATION;
|
|
9837
|
+
const cleanupCallback = async () => {
|
|
9838
|
+
this.cleanupTimeout = null;
|
|
9839
|
+
if (this.isDisposed) {
|
|
9840
|
+
return;
|
|
9841
|
+
}
|
|
9842
|
+
try {
|
|
9843
|
+
await this.cleanupResources();
|
|
9844
|
+
}
|
|
9845
|
+
catch (ex) {
|
|
9846
|
+
this.db.logger.error(`Caught error while attempting to cleanup triggers`, ex);
|
|
9847
|
+
}
|
|
9848
|
+
finally {
|
|
9849
|
+
// if not closed, set another timeout
|
|
9850
|
+
if (this.isDisposed) {
|
|
9851
|
+
return;
|
|
9852
|
+
}
|
|
9853
|
+
this.cleanupTimeout = setTimeout(cleanupCallback, TRIGGER_CLEANUP_INTERVAL_MS);
|
|
9854
|
+
}
|
|
9855
|
+
};
|
|
9856
|
+
this.cleanupTimeout = setTimeout(cleanupCallback, TRIGGER_CLEANUP_INTERVAL_MS);
|
|
8609
9857
|
}
|
|
8610
9858
|
get db() {
|
|
8611
9859
|
return this.options.db;
|
|
@@ -8623,13 +9871,95 @@ class TriggerManagerImpl {
|
|
|
8623
9871
|
await tx.execute(/* sql */ `DROP TRIGGER IF EXISTS ${triggerId}; `);
|
|
8624
9872
|
}
|
|
8625
9873
|
}
|
|
9874
|
+
dispose() {
|
|
9875
|
+
this.isDisposed = true;
|
|
9876
|
+
if (this.cleanupTimeout) {
|
|
9877
|
+
clearTimeout(this.cleanupTimeout);
|
|
9878
|
+
}
|
|
9879
|
+
}
|
|
9880
|
+
/**
|
|
9881
|
+
* Updates default config settings for platform specific use-cases.
|
|
9882
|
+
*/
|
|
9883
|
+
updateDefaults(config) {
|
|
9884
|
+
this.defaultConfig = {
|
|
9885
|
+
...this.defaultConfig,
|
|
9886
|
+
...config
|
|
9887
|
+
};
|
|
9888
|
+
}
|
|
9889
|
+
generateTriggerName(operation, destinationTable, triggerId) {
|
|
9890
|
+
return `__ps_temp_trigger_${operation.toLowerCase()}__${destinationTable}__${triggerId}`;
|
|
9891
|
+
}
|
|
9892
|
+
/**
|
|
9893
|
+
* Cleanup any SQLite triggers or tables that are no longer in use.
|
|
9894
|
+
*/
|
|
9895
|
+
async cleanupResources() {
|
|
9896
|
+
// we use the database here since cleanupResources is called during the PowerSyncDatabase initialization
|
|
9897
|
+
await this.db.database.writeLock(async (ctx) => {
|
|
9898
|
+
/**
|
|
9899
|
+
* Note: We only cleanup persisted triggers. These are tracked in the sqlite_master table.
|
|
9900
|
+
* temporary triggers will not be affected by this.
|
|
9901
|
+
* Query all triggers that match our naming pattern
|
|
9902
|
+
*/
|
|
9903
|
+
const triggers = await ctx.getAll(/* sql */ `
|
|
9904
|
+
SELECT
|
|
9905
|
+
name
|
|
9906
|
+
FROM
|
|
9907
|
+
sqlite_master
|
|
9908
|
+
WHERE
|
|
9909
|
+
type = 'trigger'
|
|
9910
|
+
AND name LIKE '__ps_temp_trigger_%'
|
|
9911
|
+
`);
|
|
9912
|
+
/** Use regex to extract table names and IDs from trigger names
|
|
9913
|
+
* Trigger naming convention: __ps_temp_trigger_<operation>__<destination_table>__<id>
|
|
9914
|
+
*/
|
|
9915
|
+
const triggerPattern = /^__ps_temp_trigger_(?:insert|update|delete)__(.+)__([a-f0-9_]{36})$/i;
|
|
9916
|
+
const trackedItems = new Map();
|
|
9917
|
+
for (const trigger of triggers) {
|
|
9918
|
+
const match = trigger.name.match(triggerPattern);
|
|
9919
|
+
if (match) {
|
|
9920
|
+
const [, table, id] = match;
|
|
9921
|
+
// Collect all trigger names for each id combo
|
|
9922
|
+
const existing = trackedItems.get(id);
|
|
9923
|
+
if (existing) {
|
|
9924
|
+
existing.triggerNames.push(trigger.name);
|
|
9925
|
+
}
|
|
9926
|
+
else {
|
|
9927
|
+
trackedItems.set(id, { table, id, triggerNames: [trigger.name] });
|
|
9928
|
+
}
|
|
9929
|
+
}
|
|
9930
|
+
}
|
|
9931
|
+
for (const trackedItem of trackedItems.values()) {
|
|
9932
|
+
// check if there is anything holding on to this item
|
|
9933
|
+
const hasClaim = await this.options.claimManager.checkClaim(trackedItem.id);
|
|
9934
|
+
if (hasClaim) {
|
|
9935
|
+
// This does not require cleanup
|
|
9936
|
+
continue;
|
|
9937
|
+
}
|
|
9938
|
+
this.db.logger.debug(`Clearing resources for trigger ${trackedItem.id} with table ${trackedItem.table}`);
|
|
9939
|
+
// We need to delete the triggers and table
|
|
9940
|
+
for (const triggerName of trackedItem.triggerNames) {
|
|
9941
|
+
await ctx.execute(`DROP TRIGGER IF EXISTS ${triggerName}`);
|
|
9942
|
+
}
|
|
9943
|
+
await ctx.execute(`DROP TABLE IF EXISTS ${trackedItem.table}`);
|
|
9944
|
+
}
|
|
9945
|
+
});
|
|
9946
|
+
}
|
|
8626
9947
|
async createDiffTrigger(options) {
|
|
8627
9948
|
await this.db.waitForReady();
|
|
8628
|
-
const { source, destination, columns, when, hooks
|
|
9949
|
+
const { source, destination, columns, when, hooks,
|
|
9950
|
+
// Fall back to the provided default if not given on this level
|
|
9951
|
+
useStorage = this.defaultConfig.useStorageByDefault } = options;
|
|
8629
9952
|
const operations = Object.keys(when);
|
|
8630
9953
|
if (operations.length == 0) {
|
|
8631
9954
|
throw new Error('At least one WHEN operation must be specified for the trigger.');
|
|
8632
9955
|
}
|
|
9956
|
+
/**
|
|
9957
|
+
* The clause to use when executing
|
|
9958
|
+
* CREATE ${tableTriggerTypeClause} TABLE
|
|
9959
|
+
* OR
|
|
9960
|
+
* CREATE ${tableTriggerTypeClause} TRIGGER
|
|
9961
|
+
*/
|
|
9962
|
+
const tableTriggerTypeClause = !useStorage ? 'TEMP' : '';
|
|
8633
9963
|
const whenClauses = Object.fromEntries(Object.entries(when).map(([operation, filter]) => [operation, `WHEN ${filter}`]));
|
|
8634
9964
|
/**
|
|
8635
9965
|
* Allow specifying the View name as the source.
|
|
@@ -8643,6 +9973,7 @@ class TriggerManagerImpl {
|
|
|
8643
9973
|
const internalSource = sourceDefinition.internalName;
|
|
8644
9974
|
const triggerIds = [];
|
|
8645
9975
|
const id = await this.getUUID();
|
|
9976
|
+
const releaseStorageClaim = useStorage ? await this.options.claimManager.obtainClaim(id) : null;
|
|
8646
9977
|
/**
|
|
8647
9978
|
* We default to replicating all columns if no columns array is provided.
|
|
8648
9979
|
*/
|
|
@@ -8675,26 +10006,27 @@ class TriggerManagerImpl {
|
|
|
8675
10006
|
return this.db.writeLock(async (tx) => {
|
|
8676
10007
|
await this.removeTriggers(tx, triggerIds);
|
|
8677
10008
|
await tx.execute(/* sql */ `DROP TABLE IF EXISTS ${destination};`);
|
|
10009
|
+
await releaseStorageClaim?.();
|
|
8678
10010
|
});
|
|
8679
10011
|
};
|
|
8680
10012
|
const setup = async (tx) => {
|
|
8681
10013
|
// Allow user code to execute in this lock context before the trigger is created.
|
|
8682
10014
|
await hooks?.beforeCreate?.(tx);
|
|
8683
10015
|
await tx.execute(/* sql */ `
|
|
8684
|
-
CREATE
|
|
10016
|
+
CREATE ${tableTriggerTypeClause} TABLE ${destination} (
|
|
8685
10017
|
operation_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
8686
10018
|
id TEXT,
|
|
8687
10019
|
operation TEXT,
|
|
8688
10020
|
timestamp TEXT,
|
|
8689
10021
|
value TEXT,
|
|
8690
10022
|
previous_value TEXT
|
|
8691
|
-
)
|
|
10023
|
+
)
|
|
8692
10024
|
`);
|
|
8693
10025
|
if (operations.includes(exports.DiffTriggerOperation.INSERT)) {
|
|
8694
|
-
const insertTriggerId =
|
|
10026
|
+
const insertTriggerId = this.generateTriggerName(exports.DiffTriggerOperation.INSERT, destination, id);
|
|
8695
10027
|
triggerIds.push(insertTriggerId);
|
|
8696
10028
|
await tx.execute(/* sql */ `
|
|
8697
|
-
CREATE
|
|
10029
|
+
CREATE ${tableTriggerTypeClause} TRIGGER ${insertTriggerId} AFTER INSERT ON ${internalSource} ${whenClauses[exports.DiffTriggerOperation.INSERT]} BEGIN
|
|
8698
10030
|
INSERT INTO
|
|
8699
10031
|
${destination} (id, operation, timestamp, value)
|
|
8700
10032
|
VALUES
|
|
@@ -8705,14 +10037,14 @@ class TriggerManagerImpl {
|
|
|
8705
10037
|
${jsonFragment('NEW')}
|
|
8706
10038
|
);
|
|
8707
10039
|
|
|
8708
|
-
END
|
|
10040
|
+
END
|
|
8709
10041
|
`);
|
|
8710
10042
|
}
|
|
8711
10043
|
if (operations.includes(exports.DiffTriggerOperation.UPDATE)) {
|
|
8712
|
-
const updateTriggerId =
|
|
10044
|
+
const updateTriggerId = this.generateTriggerName(exports.DiffTriggerOperation.UPDATE, destination, id);
|
|
8713
10045
|
triggerIds.push(updateTriggerId);
|
|
8714
10046
|
await tx.execute(/* sql */ `
|
|
8715
|
-
CREATE
|
|
10047
|
+
CREATE ${tableTriggerTypeClause} TRIGGER ${updateTriggerId} AFTER
|
|
8716
10048
|
UPDATE ON ${internalSource} ${whenClauses[exports.DiffTriggerOperation.UPDATE]} BEGIN
|
|
8717
10049
|
INSERT INTO
|
|
8718
10050
|
${destination} (id, operation, timestamp, value, previous_value)
|
|
@@ -8729,11 +10061,11 @@ class TriggerManagerImpl {
|
|
|
8729
10061
|
`);
|
|
8730
10062
|
}
|
|
8731
10063
|
if (operations.includes(exports.DiffTriggerOperation.DELETE)) {
|
|
8732
|
-
const deleteTriggerId =
|
|
10064
|
+
const deleteTriggerId = this.generateTriggerName(exports.DiffTriggerOperation.DELETE, destination, id);
|
|
8733
10065
|
triggerIds.push(deleteTriggerId);
|
|
8734
10066
|
// Create delete trigger for basic JSON
|
|
8735
10067
|
await tx.execute(/* sql */ `
|
|
8736
|
-
CREATE
|
|
10068
|
+
CREATE ${tableTriggerTypeClause} TRIGGER ${deleteTriggerId} AFTER DELETE ON ${internalSource} ${whenClauses[exports.DiffTriggerOperation.DELETE]} BEGIN
|
|
8737
10069
|
INSERT INTO
|
|
8738
10070
|
${destination} (id, operation, timestamp, value)
|
|
8739
10071
|
VALUES
|
|
@@ -8777,7 +10109,7 @@ class TriggerManagerImpl {
|
|
|
8777
10109
|
// If no array is provided, we use all columns from the source table.
|
|
8778
10110
|
const contextColumns = columns ?? sourceDefinition.columns.map((col) => col.name);
|
|
8779
10111
|
const id = await this.getUUID();
|
|
8780
|
-
const destination = `
|
|
10112
|
+
const destination = `__ps_temp_track_${source}_${id}`;
|
|
8781
10113
|
// register an onChange before the trigger is created
|
|
8782
10114
|
const abortController = new AbortController();
|
|
8783
10115
|
const abortOnChange = () => abortController.abort();
|
|
@@ -8932,6 +10264,7 @@ class AbstractPowerSyncDatabase extends BaseObserver {
|
|
|
8932
10264
|
* Allows creating SQLite triggers which can be used to track various operations on SQLite tables.
|
|
8933
10265
|
*/
|
|
8934
10266
|
triggers;
|
|
10267
|
+
triggersImpl;
|
|
8935
10268
|
logger;
|
|
8936
10269
|
constructor(options) {
|
|
8937
10270
|
super();
|
|
@@ -8995,9 +10328,10 @@ class AbstractPowerSyncDatabase extends BaseObserver {
|
|
|
8995
10328
|
logger: this.logger
|
|
8996
10329
|
});
|
|
8997
10330
|
this._isReadyPromise = this.initialize();
|
|
8998
|
-
this.triggers = new TriggerManagerImpl({
|
|
10331
|
+
this.triggers = this.triggersImpl = new TriggerManagerImpl({
|
|
8999
10332
|
db: this,
|
|
9000
|
-
schema: this.schema
|
|
10333
|
+
schema: this.schema,
|
|
10334
|
+
...this.generateTriggerManagerConfig()
|
|
9001
10335
|
});
|
|
9002
10336
|
}
|
|
9003
10337
|
/**
|
|
@@ -9023,6 +10357,15 @@ class AbstractPowerSyncDatabase extends BaseObserver {
|
|
|
9023
10357
|
get connecting() {
|
|
9024
10358
|
return this.currentStatus?.connecting || false;
|
|
9025
10359
|
}
|
|
10360
|
+
/**
|
|
10361
|
+
* Generates a base configuration for {@link TriggerManagerImpl}.
|
|
10362
|
+
* Implementations should override this if necessary.
|
|
10363
|
+
*/
|
|
10364
|
+
generateTriggerManagerConfig() {
|
|
10365
|
+
return {
|
|
10366
|
+
claimManager: MEMORY_TRIGGER_CLAIM_MANAGER
|
|
10367
|
+
};
|
|
10368
|
+
}
|
|
9026
10369
|
/**
|
|
9027
10370
|
* @returns A promise which will resolve once initialization is completed.
|
|
9028
10371
|
*/
|
|
@@ -9087,6 +10430,7 @@ class AbstractPowerSyncDatabase extends BaseObserver {
|
|
|
9087
10430
|
await this.updateSchema(this.options.schema);
|
|
9088
10431
|
await this.resolveOfflineSyncStatus();
|
|
9089
10432
|
await this.database.execute('PRAGMA RECURSIVE_TRIGGERS=TRUE');
|
|
10433
|
+
await this.triggersImpl.cleanupResources();
|
|
9090
10434
|
this.ready = true;
|
|
9091
10435
|
this.iterateListeners((cb) => cb.initialized?.());
|
|
9092
10436
|
}
|
|
@@ -9206,7 +10550,6 @@ class AbstractPowerSyncDatabase extends BaseObserver {
|
|
|
9206
10550
|
await this.disconnect();
|
|
9207
10551
|
await this.waitForReady();
|
|
9208
10552
|
const { clearLocal } = options;
|
|
9209
|
-
// TODO DB name, verify this is necessary with extension
|
|
9210
10553
|
await this.database.writeTransaction(async (tx) => {
|
|
9211
10554
|
await tx.execute('SELECT powersync_clear(?)', [clearLocal ? 1 : 0]);
|
|
9212
10555
|
});
|
|
@@ -9238,6 +10581,7 @@ class AbstractPowerSyncDatabase extends BaseObserver {
|
|
|
9238
10581
|
if (this.closed) {
|
|
9239
10582
|
return;
|
|
9240
10583
|
}
|
|
10584
|
+
this.triggersImpl.dispose();
|
|
9241
10585
|
await this.iterateAsyncListeners(async (cb) => cb.closing?.());
|
|
9242
10586
|
const { disconnect } = options;
|
|
9243
10587
|
if (disconnect) {
|
|
@@ -10203,151 +11547,50 @@ class SqliteBucketStorage extends BaseObserver {
|
|
|
10203
11547
|
]);
|
|
10204
11548
|
return r != 0;
|
|
10205
11549
|
}
|
|
10206
|
-
async migrateToFixedSubkeys() {
|
|
10207
|
-
await this.writeTransaction(async (tx) => {
|
|
10208
|
-
await tx.execute('UPDATE ps_oplog SET key = powersync_remove_duplicate_key_encoding(key);');
|
|
10209
|
-
await tx.execute('INSERT OR REPLACE INTO ps_kv (key, value) VALUES (?, ?);', [
|
|
10210
|
-
SqliteBucketStorage._subkeyMigrationKey,
|
|
10211
|
-
'1'
|
|
10212
|
-
]);
|
|
10213
|
-
});
|
|
10214
|
-
}
|
|
10215
|
-
static _subkeyMigrationKey = 'powersync_js_migrated_subkeys';
|
|
10216
|
-
}
|
|
10217
|
-
function hasMatchingPriority(priority, bucket) {
|
|
10218
|
-
return bucket.priority != null && bucket.priority <= priority;
|
|
10219
|
-
}
|
|
10220
|
-
|
|
10221
|
-
// TODO JSON
|
|
10222
|
-
class SyncDataBatch {
|
|
10223
|
-
buckets;
|
|
10224
|
-
static fromJSON(json) {
|
|
10225
|
-
return new SyncDataBatch(json.buckets.map((bucket) => SyncDataBucket.fromRow(bucket)));
|
|
10226
|
-
}
|
|
10227
|
-
constructor(buckets) {
|
|
10228
|
-
this.buckets = buckets;
|
|
10229
|
-
}
|
|
10230
|
-
}
|
|
10231
|
-
|
|
10232
|
-
/**
|
|
10233
|
-
* Thrown when an underlying database connection is closed.
|
|
10234
|
-
* This is particularly relevant when worker connections are marked as closed while
|
|
10235
|
-
* operations are still in progress.
|
|
10236
|
-
*/
|
|
10237
|
-
class ConnectionClosedError extends Error {
|
|
10238
|
-
static NAME = 'ConnectionClosedError';
|
|
10239
|
-
static MATCHES(input) {
|
|
10240
|
-
/**
|
|
10241
|
-
* If there are weird package issues which cause multiple versions of classes to be present, the instanceof
|
|
10242
|
-
* check might fail. This also performs a failsafe check.
|
|
10243
|
-
* This might also happen if the Error is serialized and parsed over a bridging channel like a MessagePort.
|
|
10244
|
-
*/
|
|
10245
|
-
return (input instanceof ConnectionClosedError || (input instanceof Error && input.name == ConnectionClosedError.NAME));
|
|
10246
|
-
}
|
|
10247
|
-
constructor(message) {
|
|
10248
|
-
super(message);
|
|
10249
|
-
this.name = ConnectionClosedError.NAME;
|
|
10250
|
-
}
|
|
10251
|
-
}
|
|
10252
|
-
|
|
10253
|
-
// https://www.sqlite.org/lang_expr.html#castexpr
|
|
10254
|
-
exports.ColumnType = void 0;
|
|
10255
|
-
(function (ColumnType) {
|
|
10256
|
-
ColumnType["TEXT"] = "TEXT";
|
|
10257
|
-
ColumnType["INTEGER"] = "INTEGER";
|
|
10258
|
-
ColumnType["REAL"] = "REAL";
|
|
10259
|
-
})(exports.ColumnType || (exports.ColumnType = {}));
|
|
10260
|
-
const text = {
|
|
10261
|
-
type: exports.ColumnType.TEXT
|
|
10262
|
-
};
|
|
10263
|
-
const integer = {
|
|
10264
|
-
type: exports.ColumnType.INTEGER
|
|
10265
|
-
};
|
|
10266
|
-
const real = {
|
|
10267
|
-
type: exports.ColumnType.REAL
|
|
10268
|
-
};
|
|
10269
|
-
// powersync-sqlite-core limits the number of column per table to 1999, due to internal SQLite limits.
|
|
10270
|
-
// In earlier versions this was limited to 63.
|
|
10271
|
-
const MAX_AMOUNT_OF_COLUMNS = 1999;
|
|
10272
|
-
const column = {
|
|
10273
|
-
text,
|
|
10274
|
-
integer,
|
|
10275
|
-
real
|
|
10276
|
-
};
|
|
10277
|
-
class Column {
|
|
10278
|
-
options;
|
|
10279
|
-
constructor(options) {
|
|
10280
|
-
this.options = options;
|
|
10281
|
-
}
|
|
10282
|
-
get name() {
|
|
10283
|
-
return this.options.name;
|
|
10284
|
-
}
|
|
10285
|
-
get type() {
|
|
10286
|
-
return this.options.type;
|
|
10287
|
-
}
|
|
10288
|
-
toJSON() {
|
|
10289
|
-
return {
|
|
10290
|
-
name: this.name,
|
|
10291
|
-
type: this.type
|
|
10292
|
-
};
|
|
10293
|
-
}
|
|
10294
|
-
}
|
|
10295
|
-
|
|
10296
|
-
const DEFAULT_INDEX_COLUMN_OPTIONS = {
|
|
10297
|
-
ascending: true
|
|
10298
|
-
};
|
|
10299
|
-
class IndexedColumn {
|
|
10300
|
-
options;
|
|
10301
|
-
static createAscending(column) {
|
|
10302
|
-
return new IndexedColumn({
|
|
10303
|
-
name: column,
|
|
10304
|
-
ascending: true
|
|
10305
|
-
});
|
|
10306
|
-
}
|
|
10307
|
-
constructor(options) {
|
|
10308
|
-
this.options = { ...DEFAULT_INDEX_COLUMN_OPTIONS, ...options };
|
|
10309
|
-
}
|
|
10310
|
-
get name() {
|
|
10311
|
-
return this.options.name;
|
|
10312
|
-
}
|
|
10313
|
-
get ascending() {
|
|
10314
|
-
return this.options.ascending;
|
|
10315
|
-
}
|
|
10316
|
-
toJSON(table) {
|
|
10317
|
-
return {
|
|
10318
|
-
name: this.name,
|
|
10319
|
-
ascending: this.ascending,
|
|
10320
|
-
type: table.columns.find((column) => column.name === this.name)?.type ?? exports.ColumnType.TEXT
|
|
10321
|
-
};
|
|
10322
|
-
}
|
|
10323
|
-
}
|
|
10324
|
-
|
|
10325
|
-
const DEFAULT_INDEX_OPTIONS = {
|
|
10326
|
-
columns: []
|
|
10327
|
-
};
|
|
10328
|
-
class Index {
|
|
10329
|
-
options;
|
|
10330
|
-
static createAscending(options, columnNames) {
|
|
10331
|
-
return new Index({
|
|
10332
|
-
...options,
|
|
10333
|
-
columns: columnNames.map((name) => IndexedColumn.createAscending(name))
|
|
11550
|
+
async migrateToFixedSubkeys() {
|
|
11551
|
+
await this.writeTransaction(async (tx) => {
|
|
11552
|
+
await tx.execute('UPDATE ps_oplog SET key = powersync_remove_duplicate_key_encoding(key);');
|
|
11553
|
+
await tx.execute('INSERT OR REPLACE INTO ps_kv (key, value) VALUES (?, ?);', [
|
|
11554
|
+
SqliteBucketStorage._subkeyMigrationKey,
|
|
11555
|
+
'1'
|
|
11556
|
+
]);
|
|
10334
11557
|
});
|
|
10335
11558
|
}
|
|
10336
|
-
|
|
10337
|
-
|
|
10338
|
-
|
|
11559
|
+
static _subkeyMigrationKey = 'powersync_js_migrated_subkeys';
|
|
11560
|
+
}
|
|
11561
|
+
function hasMatchingPriority(priority, bucket) {
|
|
11562
|
+
return bucket.priority != null && bucket.priority <= priority;
|
|
11563
|
+
}
|
|
11564
|
+
|
|
11565
|
+
// TODO JSON
|
|
11566
|
+
class SyncDataBatch {
|
|
11567
|
+
buckets;
|
|
11568
|
+
static fromJSON(json) {
|
|
11569
|
+
return new SyncDataBatch(json.buckets.map((bucket) => SyncDataBucket.fromRow(bucket)));
|
|
10339
11570
|
}
|
|
10340
|
-
|
|
10341
|
-
|
|
11571
|
+
constructor(buckets) {
|
|
11572
|
+
this.buckets = buckets;
|
|
10342
11573
|
}
|
|
10343
|
-
|
|
10344
|
-
|
|
11574
|
+
}
|
|
11575
|
+
|
|
11576
|
+
/**
|
|
11577
|
+
* Thrown when an underlying database connection is closed.
|
|
11578
|
+
* This is particularly relevant when worker connections are marked as closed while
|
|
11579
|
+
* operations are still in progress.
|
|
11580
|
+
*/
|
|
11581
|
+
class ConnectionClosedError extends Error {
|
|
11582
|
+
static NAME = 'ConnectionClosedError';
|
|
11583
|
+
static MATCHES(input) {
|
|
11584
|
+
/**
|
|
11585
|
+
* If there are weird package issues which cause multiple versions of classes to be present, the instanceof
|
|
11586
|
+
* check might fail. This also performs a failsafe check.
|
|
11587
|
+
* This might also happen if the Error is serialized and parsed over a bridging channel like a MessagePort.
|
|
11588
|
+
*/
|
|
11589
|
+
return (input instanceof ConnectionClosedError || (input instanceof Error && input.name == ConnectionClosedError.NAME));
|
|
10345
11590
|
}
|
|
10346
|
-
|
|
10347
|
-
|
|
10348
|
-
|
|
10349
|
-
columns: this.columns.map((c) => c.toJSON(table))
|
|
10350
|
-
};
|
|
11591
|
+
constructor(message) {
|
|
11592
|
+
super(message);
|
|
11593
|
+
this.name = ConnectionClosedError.NAME;
|
|
10351
11594
|
}
|
|
10352
11595
|
}
|
|
10353
11596
|
|
|
@@ -10444,211 +11687,6 @@ class Schema {
|
|
|
10444
11687
|
}
|
|
10445
11688
|
}
|
|
10446
11689
|
|
|
10447
|
-
const DEFAULT_TABLE_OPTIONS = {
|
|
10448
|
-
indexes: [],
|
|
10449
|
-
insertOnly: false,
|
|
10450
|
-
localOnly: false,
|
|
10451
|
-
trackPrevious: false,
|
|
10452
|
-
trackMetadata: false,
|
|
10453
|
-
ignoreEmptyUpdates: false
|
|
10454
|
-
};
|
|
10455
|
-
const InvalidSQLCharacters = /["'%,.#\s[\]]/;
|
|
10456
|
-
class Table {
|
|
10457
|
-
options;
|
|
10458
|
-
_mappedColumns;
|
|
10459
|
-
static createLocalOnly(options) {
|
|
10460
|
-
return new Table({ ...options, localOnly: true, insertOnly: false });
|
|
10461
|
-
}
|
|
10462
|
-
static createInsertOnly(options) {
|
|
10463
|
-
return new Table({ ...options, localOnly: false, insertOnly: true });
|
|
10464
|
-
}
|
|
10465
|
-
/**
|
|
10466
|
-
* Create a table.
|
|
10467
|
-
* @deprecated This was only only included for TableV2 and is no longer necessary.
|
|
10468
|
-
* Prefer to use new Table() directly.
|
|
10469
|
-
*
|
|
10470
|
-
* TODO remove in the next major release.
|
|
10471
|
-
*/
|
|
10472
|
-
static createTable(name, table) {
|
|
10473
|
-
return new Table({
|
|
10474
|
-
name,
|
|
10475
|
-
columns: table.columns,
|
|
10476
|
-
indexes: table.indexes,
|
|
10477
|
-
localOnly: table.options.localOnly,
|
|
10478
|
-
insertOnly: table.options.insertOnly,
|
|
10479
|
-
viewName: table.options.viewName
|
|
10480
|
-
});
|
|
10481
|
-
}
|
|
10482
|
-
constructor(optionsOrColumns, v2Options) {
|
|
10483
|
-
if (this.isTableV1(optionsOrColumns)) {
|
|
10484
|
-
this.initTableV1(optionsOrColumns);
|
|
10485
|
-
}
|
|
10486
|
-
else {
|
|
10487
|
-
this.initTableV2(optionsOrColumns, v2Options);
|
|
10488
|
-
}
|
|
10489
|
-
}
|
|
10490
|
-
copyWithName(name) {
|
|
10491
|
-
return new Table({
|
|
10492
|
-
...this.options,
|
|
10493
|
-
name
|
|
10494
|
-
});
|
|
10495
|
-
}
|
|
10496
|
-
isTableV1(arg) {
|
|
10497
|
-
return 'columns' in arg && Array.isArray(arg.columns);
|
|
10498
|
-
}
|
|
10499
|
-
initTableV1(options) {
|
|
10500
|
-
this.options = {
|
|
10501
|
-
...options,
|
|
10502
|
-
indexes: options.indexes || []
|
|
10503
|
-
};
|
|
10504
|
-
this.applyDefaultOptions();
|
|
10505
|
-
}
|
|
10506
|
-
initTableV2(columns, options) {
|
|
10507
|
-
const convertedColumns = Object.entries(columns).map(([name, columnInfo]) => new Column({ name, type: columnInfo.type }));
|
|
10508
|
-
const convertedIndexes = Object.entries(options?.indexes ?? {}).map(([name, columnNames]) => new Index({
|
|
10509
|
-
name,
|
|
10510
|
-
columns: columnNames.map((name) => new IndexedColumn({
|
|
10511
|
-
name: name.replace(/^-/, ''),
|
|
10512
|
-
ascending: !name.startsWith('-')
|
|
10513
|
-
}))
|
|
10514
|
-
}));
|
|
10515
|
-
this.options = {
|
|
10516
|
-
name: '',
|
|
10517
|
-
columns: convertedColumns,
|
|
10518
|
-
indexes: convertedIndexes,
|
|
10519
|
-
viewName: options?.viewName,
|
|
10520
|
-
insertOnly: options?.insertOnly,
|
|
10521
|
-
localOnly: options?.localOnly,
|
|
10522
|
-
trackPrevious: options?.trackPrevious,
|
|
10523
|
-
trackMetadata: options?.trackMetadata,
|
|
10524
|
-
ignoreEmptyUpdates: options?.ignoreEmptyUpdates
|
|
10525
|
-
};
|
|
10526
|
-
this.applyDefaultOptions();
|
|
10527
|
-
this._mappedColumns = columns;
|
|
10528
|
-
}
|
|
10529
|
-
applyDefaultOptions() {
|
|
10530
|
-
this.options.insertOnly ??= DEFAULT_TABLE_OPTIONS.insertOnly;
|
|
10531
|
-
this.options.localOnly ??= DEFAULT_TABLE_OPTIONS.localOnly;
|
|
10532
|
-
this.options.trackPrevious ??= DEFAULT_TABLE_OPTIONS.trackPrevious;
|
|
10533
|
-
this.options.trackMetadata ??= DEFAULT_TABLE_OPTIONS.trackMetadata;
|
|
10534
|
-
this.options.ignoreEmptyUpdates ??= DEFAULT_TABLE_OPTIONS.ignoreEmptyUpdates;
|
|
10535
|
-
}
|
|
10536
|
-
get name() {
|
|
10537
|
-
return this.options.name;
|
|
10538
|
-
}
|
|
10539
|
-
get viewNameOverride() {
|
|
10540
|
-
return this.options.viewName;
|
|
10541
|
-
}
|
|
10542
|
-
get viewName() {
|
|
10543
|
-
return this.viewNameOverride ?? this.name;
|
|
10544
|
-
}
|
|
10545
|
-
get columns() {
|
|
10546
|
-
return this.options.columns;
|
|
10547
|
-
}
|
|
10548
|
-
get columnMap() {
|
|
10549
|
-
return (this._mappedColumns ??
|
|
10550
|
-
this.columns.reduce((hash, column) => {
|
|
10551
|
-
hash[column.name] = { type: column.type ?? exports.ColumnType.TEXT };
|
|
10552
|
-
return hash;
|
|
10553
|
-
}, {}));
|
|
10554
|
-
}
|
|
10555
|
-
get indexes() {
|
|
10556
|
-
return this.options.indexes ?? [];
|
|
10557
|
-
}
|
|
10558
|
-
get localOnly() {
|
|
10559
|
-
return this.options.localOnly;
|
|
10560
|
-
}
|
|
10561
|
-
get insertOnly() {
|
|
10562
|
-
return this.options.insertOnly;
|
|
10563
|
-
}
|
|
10564
|
-
get trackPrevious() {
|
|
10565
|
-
return this.options.trackPrevious;
|
|
10566
|
-
}
|
|
10567
|
-
get trackMetadata() {
|
|
10568
|
-
return this.options.trackMetadata;
|
|
10569
|
-
}
|
|
10570
|
-
get ignoreEmptyUpdates() {
|
|
10571
|
-
return this.options.ignoreEmptyUpdates;
|
|
10572
|
-
}
|
|
10573
|
-
get internalName() {
|
|
10574
|
-
if (this.options.localOnly) {
|
|
10575
|
-
return `ps_data_local__${this.name}`;
|
|
10576
|
-
}
|
|
10577
|
-
return `ps_data__${this.name}`;
|
|
10578
|
-
}
|
|
10579
|
-
get validName() {
|
|
10580
|
-
if (InvalidSQLCharacters.test(this.name)) {
|
|
10581
|
-
return false;
|
|
10582
|
-
}
|
|
10583
|
-
if (this.viewNameOverride != null && InvalidSQLCharacters.test(this.viewNameOverride)) {
|
|
10584
|
-
return false;
|
|
10585
|
-
}
|
|
10586
|
-
return true;
|
|
10587
|
-
}
|
|
10588
|
-
validate() {
|
|
10589
|
-
if (InvalidSQLCharacters.test(this.name)) {
|
|
10590
|
-
throw new Error(`Invalid characters in table name: ${this.name}`);
|
|
10591
|
-
}
|
|
10592
|
-
if (this.viewNameOverride && InvalidSQLCharacters.test(this.viewNameOverride)) {
|
|
10593
|
-
throw new Error(`Invalid characters in view name: ${this.viewNameOverride}`);
|
|
10594
|
-
}
|
|
10595
|
-
if (this.columns.length > MAX_AMOUNT_OF_COLUMNS) {
|
|
10596
|
-
throw new Error(`Table has too many columns. The maximum number of columns is ${MAX_AMOUNT_OF_COLUMNS}.`);
|
|
10597
|
-
}
|
|
10598
|
-
if (this.trackMetadata && this.localOnly) {
|
|
10599
|
-
throw new Error(`Can't include metadata for local-only tables.`);
|
|
10600
|
-
}
|
|
10601
|
-
if (this.trackPrevious != false && this.localOnly) {
|
|
10602
|
-
throw new Error(`Can't include old values for local-only tables.`);
|
|
10603
|
-
}
|
|
10604
|
-
const columnNames = new Set();
|
|
10605
|
-
columnNames.add('id');
|
|
10606
|
-
for (const column of this.columns) {
|
|
10607
|
-
const { name: columnName } = column;
|
|
10608
|
-
if (column.name === 'id') {
|
|
10609
|
-
throw new Error(`An id column is automatically added, custom id columns are not supported`);
|
|
10610
|
-
}
|
|
10611
|
-
if (columnNames.has(columnName)) {
|
|
10612
|
-
throw new Error(`Duplicate column ${columnName}`);
|
|
10613
|
-
}
|
|
10614
|
-
if (InvalidSQLCharacters.test(columnName)) {
|
|
10615
|
-
throw new Error(`Invalid characters in column name: ${column.name}`);
|
|
10616
|
-
}
|
|
10617
|
-
columnNames.add(columnName);
|
|
10618
|
-
}
|
|
10619
|
-
const indexNames = new Set();
|
|
10620
|
-
for (const index of this.indexes) {
|
|
10621
|
-
if (indexNames.has(index.name)) {
|
|
10622
|
-
throw new Error(`Duplicate index ${index.name}`);
|
|
10623
|
-
}
|
|
10624
|
-
if (InvalidSQLCharacters.test(index.name)) {
|
|
10625
|
-
throw new Error(`Invalid characters in index name: ${index.name}`);
|
|
10626
|
-
}
|
|
10627
|
-
for (const column of index.columns) {
|
|
10628
|
-
if (!columnNames.has(column.name)) {
|
|
10629
|
-
throw new Error(`Column ${column.name} not found for index ${index.name}`);
|
|
10630
|
-
}
|
|
10631
|
-
}
|
|
10632
|
-
indexNames.add(index.name);
|
|
10633
|
-
}
|
|
10634
|
-
}
|
|
10635
|
-
toJSON() {
|
|
10636
|
-
const trackPrevious = this.trackPrevious;
|
|
10637
|
-
return {
|
|
10638
|
-
name: this.name,
|
|
10639
|
-
view_name: this.viewName,
|
|
10640
|
-
local_only: this.localOnly,
|
|
10641
|
-
insert_only: this.insertOnly,
|
|
10642
|
-
include_old: trackPrevious && (trackPrevious.columns ?? true),
|
|
10643
|
-
include_old_only_when_changed: typeof trackPrevious == 'object' && trackPrevious.onlyWhenChanged == true,
|
|
10644
|
-
include_metadata: this.trackMetadata,
|
|
10645
|
-
ignore_empty_update: this.ignoreEmptyUpdates,
|
|
10646
|
-
columns: this.columns.map((c) => c.toJSON()),
|
|
10647
|
-
indexes: this.indexes.map((e) => e.toJSON(this))
|
|
10648
|
-
};
|
|
10649
|
-
}
|
|
10650
|
-
}
|
|
10651
|
-
|
|
10652
11690
|
/**
|
|
10653
11691
|
Generate a new table from the columns and indexes
|
|
10654
11692
|
@deprecated You should use {@link Table} instead as it now allows TableV2 syntax.
|
|
@@ -10804,6 +11842,7 @@ const parseQuery = (query, parameters) => {
|
|
|
10804
11842
|
return { sqlStatement, parameters: parameters };
|
|
10805
11843
|
};
|
|
10806
11844
|
|
|
11845
|
+
exports.ATTACHMENT_TABLE = ATTACHMENT_TABLE;
|
|
10807
11846
|
exports.AbortOperation = AbortOperation;
|
|
10808
11847
|
exports.AbstractPowerSyncDatabase = AbstractPowerSyncDatabase;
|
|
10809
11848
|
exports.AbstractPowerSyncDatabaseOpenFactory = AbstractPowerSyncDatabaseOpenFactory;
|
|
@@ -10811,6 +11850,10 @@ exports.AbstractQueryProcessor = AbstractQueryProcessor;
|
|
|
10811
11850
|
exports.AbstractRemote = AbstractRemote;
|
|
10812
11851
|
exports.AbstractStreamingSyncImplementation = AbstractStreamingSyncImplementation;
|
|
10813
11852
|
exports.ArrayComparator = ArrayComparator;
|
|
11853
|
+
exports.AttachmentContext = AttachmentContext;
|
|
11854
|
+
exports.AttachmentQueue = AttachmentQueue;
|
|
11855
|
+
exports.AttachmentService = AttachmentService;
|
|
11856
|
+
exports.AttachmentTable = AttachmentTable;
|
|
10814
11857
|
exports.BaseObserver = BaseObserver;
|
|
10815
11858
|
exports.Column = Column;
|
|
10816
11859
|
exports.ConnectionClosedError = ConnectionClosedError;
|
|
@@ -10849,6 +11892,7 @@ exports.InvalidSQLCharacters = InvalidSQLCharacters;
|
|
|
10849
11892
|
exports.LogLevel = LogLevel;
|
|
10850
11893
|
exports.MAX_AMOUNT_OF_COLUMNS = MAX_AMOUNT_OF_COLUMNS;
|
|
10851
11894
|
exports.MAX_OP_ID = MAX_OP_ID;
|
|
11895
|
+
exports.MEMORY_TRIGGER_CLAIM_MANAGER = MEMORY_TRIGGER_CLAIM_MANAGER;
|
|
10852
11896
|
exports.OnChangeQueryProcessor = OnChangeQueryProcessor;
|
|
10853
11897
|
exports.OpType = OpType;
|
|
10854
11898
|
exports.OplogEntry = OplogEntry;
|
|
@@ -10859,9 +11903,12 @@ exports.SyncDataBatch = SyncDataBatch;
|
|
|
10859
11903
|
exports.SyncDataBucket = SyncDataBucket;
|
|
10860
11904
|
exports.SyncProgress = SyncProgress;
|
|
10861
11905
|
exports.SyncStatus = SyncStatus;
|
|
11906
|
+
exports.SyncingService = SyncingService;
|
|
10862
11907
|
exports.Table = Table;
|
|
10863
11908
|
exports.TableV2 = TableV2;
|
|
11909
|
+
exports.TriggerManagerImpl = TriggerManagerImpl;
|
|
10864
11910
|
exports.UploadQueueStats = UploadQueueStats;
|
|
11911
|
+
exports.attachmentFromSql = attachmentFromSql;
|
|
10865
11912
|
exports.column = column;
|
|
10866
11913
|
exports.compilableQueryWatch = compilableQueryWatch;
|
|
10867
11914
|
exports.createBaseLogger = createBaseLogger;
|