@powersync/common 1.46.0 → 1.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +5 -1
  2. package/dist/bundle.cjs +1266 -360
  3. package/dist/bundle.cjs.map +1 -1
  4. package/dist/bundle.mjs +1259 -361
  5. package/dist/bundle.mjs.map +1 -1
  6. package/dist/bundle.node.cjs +1266 -360
  7. package/dist/bundle.node.cjs.map +1 -1
  8. package/dist/bundle.node.mjs +1259 -361
  9. package/dist/bundle.node.mjs.map +1 -1
  10. package/dist/index.d.cts +530 -29
  11. package/lib/attachments/AttachmentContext.d.ts +86 -0
  12. package/lib/attachments/AttachmentContext.js +229 -0
  13. package/lib/attachments/AttachmentContext.js.map +1 -0
  14. package/lib/attachments/AttachmentErrorHandler.d.ts +31 -0
  15. package/lib/attachments/AttachmentErrorHandler.js +2 -0
  16. package/lib/attachments/AttachmentErrorHandler.js.map +1 -0
  17. package/lib/attachments/AttachmentQueue.d.ts +149 -0
  18. package/lib/attachments/AttachmentQueue.js +362 -0
  19. package/lib/attachments/AttachmentQueue.js.map +1 -0
  20. package/lib/attachments/AttachmentService.d.ts +29 -0
  21. package/lib/attachments/AttachmentService.js +56 -0
  22. package/lib/attachments/AttachmentService.js.map +1 -0
  23. package/lib/attachments/LocalStorageAdapter.d.ts +62 -0
  24. package/lib/attachments/LocalStorageAdapter.js +6 -0
  25. package/lib/attachments/LocalStorageAdapter.js.map +1 -0
  26. package/lib/attachments/RemoteStorageAdapter.d.ts +27 -0
  27. package/lib/attachments/RemoteStorageAdapter.js +2 -0
  28. package/lib/attachments/RemoteStorageAdapter.js.map +1 -0
  29. package/lib/attachments/Schema.d.ts +50 -0
  30. package/lib/attachments/Schema.js +62 -0
  31. package/lib/attachments/Schema.js.map +1 -0
  32. package/lib/attachments/SyncingService.d.ts +62 -0
  33. package/lib/attachments/SyncingService.js +168 -0
  34. package/lib/attachments/SyncingService.js.map +1 -0
  35. package/lib/attachments/WatchedAttachmentItem.d.ts +17 -0
  36. package/lib/attachments/WatchedAttachmentItem.js +2 -0
  37. package/lib/attachments/WatchedAttachmentItem.js.map +1 -0
  38. package/lib/index.d.ts +10 -0
  39. package/lib/index.js +10 -0
  40. package/lib/index.js.map +1 -1
  41. package/lib/utils/mutex.d.ts +1 -1
  42. package/lib/utils/mutex.js.map +1 -1
  43. package/package.json +1 -1
  44. package/src/attachments/AttachmentContext.ts +279 -0
  45. package/src/attachments/AttachmentErrorHandler.ts +34 -0
  46. package/src/attachments/AttachmentQueue.ts +472 -0
  47. package/src/attachments/AttachmentService.ts +62 -0
  48. package/src/attachments/LocalStorageAdapter.ts +72 -0
  49. package/src/attachments/README.md +718 -0
  50. package/src/attachments/RemoteStorageAdapter.ts +30 -0
  51. package/src/attachments/Schema.ts +87 -0
  52. package/src/attachments/SyncingService.ts +193 -0
  53. package/src/attachments/WatchedAttachmentItem.ts +19 -0
  54. package/src/index.ts +11 -0
  55. package/src/utils/mutex.ts +1 -1
@@ -0,0 +1,30 @@
1
+ import { AttachmentRecord } from './Schema.js';
2
+
3
+ /**
4
+ * RemoteStorageAdapter defines the interface for remote storage operations.
5
+ * Implementations handle uploading, downloading, and deleting files from remote storage.
6
+ *
7
+ * @experimental
8
+ * @alpha This is currently experimental and may change without a major version bump.
9
+ */
10
+ export interface RemoteStorageAdapter {
11
+ /**
12
+ * Uploads a file to remote storage.
13
+ * @param fileData The binary content of the file to upload
14
+ * @param attachment The associated attachment metadata
15
+ */
16
+ uploadFile(fileData: ArrayBuffer, attachment: AttachmentRecord): Promise<void>;
17
+
18
+ /**
19
+ * Downloads a file from remote storage.
20
+ * @param attachment The attachment describing the file to download
21
+ * @returns The binary data of the downloaded file
22
+ */
23
+ downloadFile(attachment: AttachmentRecord): Promise<ArrayBuffer>;
24
+
25
+ /**
26
+ * Deletes a file from remote storage.
27
+ * @param attachment The attachment describing the file to delete
28
+ */
29
+ deleteFile(attachment: AttachmentRecord): Promise<void>;
30
+ }
@@ -0,0 +1,87 @@
1
+ import { column } from '../db/schema/Column.js';
2
+ import { Table } from '../db/schema/Table.js';
3
+ import { TableV2Options } from '../db/schema/Table.js';
4
+
5
+ export const ATTACHMENT_TABLE = 'attachments';
6
+
7
+ /**
8
+ * AttachmentRecord represents an attachment in the local database.
9
+ *
10
+ * @experimental
11
+ */
12
+ export interface AttachmentRecord {
13
+ id: string;
14
+ filename: string;
15
+ localUri?: string;
16
+ size?: number;
17
+ mediaType?: string;
18
+ timestamp?: number;
19
+ metaData?: string;
20
+ hasSynced?: boolean;
21
+ state: AttachmentState;
22
+ }
23
+
24
+ /**
25
+ * Maps a database row to an AttachmentRecord.
26
+ *
27
+ * @param row - The database row object
28
+ * @returns The corresponding AttachmentRecord
29
+ *
30
+ * @experimental
31
+ */
32
+ export function attachmentFromSql(row: any): AttachmentRecord {
33
+ return {
34
+ id: row.id,
35
+ filename: row.filename,
36
+ localUri: row.local_uri,
37
+ size: row.size,
38
+ mediaType: row.media_type,
39
+ timestamp: row.timestamp,
40
+ metaData: row.meta_data,
41
+ hasSynced: row.has_synced === 1,
42
+ state: row.state
43
+ };
44
+ }
45
+
46
+ /**
47
+ * AttachmentState represents the current synchronization state of an attachment.
48
+ *
49
+ * @experimental
50
+ */
51
+ export enum AttachmentState {
52
+ QUEUED_UPLOAD = 0, // Attachment to be uploaded
53
+ QUEUED_DOWNLOAD = 1, // Attachment to be downloaded
54
+ QUEUED_DELETE = 2, // Attachment to be deleted
55
+ SYNCED = 3, // Attachment has been synced
56
+ ARCHIVED = 4 // Attachment has been orphaned, i.e. the associated record has been deleted
57
+ }
58
+
59
+ export interface AttachmentTableOptions extends Omit<TableV2Options, 'name' | 'columns'> {}
60
+
61
+ /**
62
+ * AttachmentTable defines the schema for the attachment queue table.
63
+ *
64
+ * @internal
65
+ */
66
+ export class AttachmentTable extends Table {
67
+ constructor(options?: AttachmentTableOptions) {
68
+ super(
69
+ {
70
+ filename: column.text,
71
+ local_uri: column.text,
72
+ timestamp: column.integer,
73
+ size: column.integer,
74
+ media_type: column.text,
75
+ state: column.integer, // Corresponds to AttachmentState
76
+ has_synced: column.integer,
77
+ meta_data: column.text
78
+ },
79
+ {
80
+ ...options,
81
+ viewName: options?.viewName ?? ATTACHMENT_TABLE,
82
+ localOnly: true,
83
+ insertOnly: false
84
+ }
85
+ );
86
+ }
87
+ }
@@ -0,0 +1,193 @@
1
+ import { ILogger } from '../utils/Logger.js';
2
+ import { AttachmentService } from './AttachmentService.js';
3
+ import { LocalStorageAdapter } from './LocalStorageAdapter.js';
4
+ import { RemoteStorageAdapter } from './RemoteStorageAdapter.js';
5
+ import { AttachmentRecord, AttachmentState } from './Schema.js';
6
+ import { AttachmentErrorHandler } from './AttachmentErrorHandler.js';
7
+ import { AttachmentContext } from './AttachmentContext.js';
8
+
9
+ /**
10
+ * Orchestrates attachment synchronization between local and remote storage.
11
+ * Handles uploads, downloads, deletions, and state transitions.
12
+ *
13
+ * @internal
14
+ */
15
+ export class SyncingService {
16
+ private attachmentService: AttachmentService;
17
+ private localStorage: LocalStorageAdapter;
18
+ private remoteStorage: RemoteStorageAdapter;
19
+ private logger: ILogger;
20
+ private errorHandler?: AttachmentErrorHandler;
21
+
22
+ constructor(
23
+ attachmentService: AttachmentService,
24
+ localStorage: LocalStorageAdapter,
25
+ remoteStorage: RemoteStorageAdapter,
26
+ logger: ILogger,
27
+ errorHandler?: AttachmentErrorHandler
28
+ ) {
29
+ this.attachmentService = attachmentService;
30
+ this.localStorage = localStorage;
31
+ this.remoteStorage = remoteStorage;
32
+ this.logger = logger;
33
+ this.errorHandler = errorHandler;
34
+ }
35
+
36
+ /**
37
+ * Processes attachments based on their state (upload, download, or delete).
38
+ * All updates are saved in a single batch after processing.
39
+ *
40
+ * @param attachments - Array of attachment records to process
41
+ * @param context - Attachment context for database operations
42
+ * @returns Promise that resolves when all attachments have been processed and saved
43
+ */
44
+ async processAttachments(attachments: AttachmentRecord[], context: AttachmentContext): Promise<void> {
45
+ const updatedAttachments: AttachmentRecord[] = [];
46
+ for (const attachment of attachments) {
47
+ switch (attachment.state) {
48
+ case AttachmentState.QUEUED_UPLOAD:
49
+ const uploaded = await this.uploadAttachment(attachment);
50
+ updatedAttachments.push(uploaded);
51
+ break;
52
+ case AttachmentState.QUEUED_DOWNLOAD:
53
+ const downloaded = await this.downloadAttachment(attachment);
54
+ updatedAttachments.push(downloaded);
55
+ break;
56
+ case AttachmentState.QUEUED_DELETE:
57
+ const deleted = await this.deleteAttachment(attachment);
58
+ updatedAttachments.push(deleted);
59
+ break;
60
+
61
+ default:
62
+ break;
63
+ }
64
+ }
65
+
66
+ await context.saveAttachments(updatedAttachments);
67
+ }
68
+
69
+ /**
70
+ * Uploads an attachment from local storage to remote storage.
71
+ * On success, marks as SYNCED. On failure, defers to error handler or archives.
72
+ *
73
+ * @param attachment - The attachment record to upload
74
+ * @returns Updated attachment record with new state
75
+ * @throws Error if the attachment has no localUri
76
+ */
77
+ async uploadAttachment(attachment: AttachmentRecord): Promise<AttachmentRecord> {
78
+ this.logger.info(`Uploading attachment ${attachment.filename}`);
79
+ try {
80
+ if (attachment.localUri == null) {
81
+ throw new Error(`No localUri for attachment ${attachment.id}`);
82
+ }
83
+
84
+ const fileBlob = await this.localStorage.readFile(attachment.localUri);
85
+ await this.remoteStorage.uploadFile(fileBlob, attachment);
86
+
87
+ return {
88
+ ...attachment,
89
+ state: AttachmentState.SYNCED,
90
+ hasSynced: true
91
+ };
92
+ } catch (error) {
93
+ const shouldRetry = (await this.errorHandler?.onUploadError(attachment, error)) ?? true;
94
+ if (!shouldRetry) {
95
+ return {
96
+ ...attachment,
97
+ state: AttachmentState.ARCHIVED
98
+ };
99
+ }
100
+
101
+ return attachment;
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Downloads an attachment from remote storage to local storage.
107
+ * Retrieves the file, converts to base64, and saves locally.
108
+ * On success, marks as SYNCED. On failure, defers to error handler or archives.
109
+ *
110
+ * @param attachment - The attachment record to download
111
+ * @returns Updated attachment record with local URI and new state
112
+ */
113
+ async downloadAttachment(attachment: AttachmentRecord): Promise<AttachmentRecord> {
114
+ this.logger.info(`Downloading attachment ${attachment.filename}`);
115
+ try {
116
+ const fileData = await this.remoteStorage.downloadFile(attachment);
117
+
118
+ const localUri = this.localStorage.getLocalUri(attachment.filename);
119
+ await this.localStorage.saveFile(localUri, fileData);
120
+
121
+ return {
122
+ ...attachment,
123
+ state: AttachmentState.SYNCED,
124
+ localUri: localUri,
125
+ hasSynced: true
126
+ };
127
+ } catch (error) {
128
+ const shouldRetry = (await this.errorHandler?.onDownloadError(attachment, error)) ?? true;
129
+ if (!shouldRetry) {
130
+ return {
131
+ ...attachment,
132
+ state: AttachmentState.ARCHIVED
133
+ };
134
+ }
135
+
136
+ return attachment;
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Deletes an attachment from both remote and local storage.
142
+ * Removes the remote file, local file (if exists), and the attachment record.
143
+ * On failure, defers to error handler or archives.
144
+ *
145
+ * @param attachment - The attachment record to delete
146
+ * @returns Updated attachment record
147
+ */
148
+ async deleteAttachment(attachment: AttachmentRecord): Promise<AttachmentRecord> {
149
+ try {
150
+ await this.remoteStorage.deleteFile(attachment);
151
+ if (attachment.localUri) {
152
+ await this.localStorage.deleteFile(attachment.localUri);
153
+ }
154
+
155
+ await this.attachmentService.withContext(async (ctx) => {
156
+ await ctx.deleteAttachment(attachment.id);
157
+ });
158
+
159
+ return {
160
+ ...attachment,
161
+ state: AttachmentState.ARCHIVED
162
+ };
163
+ } catch (error) {
164
+ const shouldRetry = (await this.errorHandler?.onDeleteError(attachment, error)) ?? true;
165
+ if (!shouldRetry) {
166
+ return {
167
+ ...attachment,
168
+ state: AttachmentState.ARCHIVED
169
+ };
170
+ }
171
+
172
+ return attachment;
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Performs cleanup of archived attachments by removing their local files and records.
178
+ * Errors during local file deletion are logged but do not prevent record deletion.
179
+ */
180
+ async deleteArchivedAttachments(context: AttachmentContext): Promise<boolean> {
181
+ return await context.deleteArchivedAttachments(async (archivedAttachments) => {
182
+ for (const attachment of archivedAttachments) {
183
+ if (attachment.localUri) {
184
+ try {
185
+ await this.localStorage.deleteFile(attachment.localUri);
186
+ } catch (error) {
187
+ this.logger.error('Error deleting local file for archived attachment', error);
188
+ }
189
+ }
190
+ }
191
+ });
192
+ }
193
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * WatchedAttachmentItem represents an attachment reference in your application's data model.
3
+ * Use either filename OR fileExtension (not both).
4
+ *
5
+ * @experimental
6
+ */
7
+ export type WatchedAttachmentItem =
8
+ | {
9
+ id: string;
10
+ filename: string;
11
+ fileExtension?: never;
12
+ metaData?: string;
13
+ }
14
+ | {
15
+ id: string;
16
+ fileExtension: string;
17
+ filename?: never;
18
+ metaData?: string;
19
+ };
package/src/index.ts CHANGED
@@ -1,3 +1,13 @@
1
+ export * from './attachments/AttachmentContext.js';
2
+ export * from './attachments/AttachmentErrorHandler.js';
3
+ export * from './attachments/AttachmentQueue.js';
4
+ export * from './attachments/AttachmentService.js';
5
+ export * from './attachments/LocalStorageAdapter.js';
6
+ export * from './attachments/RemoteStorageAdapter.js';
7
+ export * from './attachments/Schema.js';
8
+ export * from './attachments/SyncingService.js';
9
+ export * from './attachments/WatchedAttachmentItem.js';
10
+
1
11
  export * from './client/AbstractPowerSyncDatabase.js';
2
12
  export * from './client/AbstractPowerSyncOpenFactory.js';
3
13
  export { compilableQueryWatch, CompilableQueryWatchHandler } from './client/compilableQueryWatch.js';
@@ -51,6 +61,7 @@ export * from './utils/BaseObserver.js';
51
61
  export * from './utils/ControlledExecutor.js';
52
62
  export * from './utils/DataStream.js';
53
63
  export * from './utils/Logger.js';
64
+ export * from './utils/mutex.js';
54
65
  export * from './utils/parseQuery.js';
55
66
 
56
67
  export * from './types/types.js';
@@ -6,7 +6,7 @@ import { Mutex } from 'async-mutex';
6
6
  export async function mutexRunExclusive<T>(
7
7
  mutex: Mutex,
8
8
  callback: () => Promise<T>,
9
- options?: { timeoutMs: number }
9
+ options?: { timeoutMs?: number }
10
10
  ): Promise<T> {
11
11
  return new Promise((resolve, reject) => {
12
12
  const timeout = options?.timeoutMs;