@powersync/web 1.32.0 → 1.33.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.
@@ -2417,6 +2417,7 @@ function generateUUID() {
2417
2417
 
2418
2418
  __webpack_require__.r(__webpack_exports__);
2419
2419
  /* harmony export */ __webpack_require__.d(__webpack_exports__, {
2420
+ /* harmony export */ ATTACHMENT_TABLE: () => (/* binding */ ATTACHMENT_TABLE),
2420
2421
  /* harmony export */ AbortOperation: () => (/* binding */ AbortOperation),
2421
2422
  /* harmony export */ AbstractPowerSyncDatabase: () => (/* binding */ AbstractPowerSyncDatabase),
2422
2423
  /* harmony export */ AbstractPowerSyncDatabaseOpenFactory: () => (/* binding */ AbstractPowerSyncDatabaseOpenFactory),
@@ -2424,6 +2425,11 @@ __webpack_require__.r(__webpack_exports__);
2424
2425
  /* harmony export */ AbstractRemote: () => (/* binding */ AbstractRemote),
2425
2426
  /* harmony export */ AbstractStreamingSyncImplementation: () => (/* binding */ AbstractStreamingSyncImplementation),
2426
2427
  /* harmony export */ ArrayComparator: () => (/* binding */ ArrayComparator),
2428
+ /* harmony export */ AttachmentContext: () => (/* binding */ AttachmentContext),
2429
+ /* harmony export */ AttachmentQueue: () => (/* binding */ AttachmentQueue),
2430
+ /* harmony export */ AttachmentService: () => (/* binding */ AttachmentService),
2431
+ /* harmony export */ AttachmentState: () => (/* binding */ AttachmentState),
2432
+ /* harmony export */ AttachmentTable: () => (/* binding */ AttachmentTable),
2427
2433
  /* harmony export */ BaseObserver: () => (/* binding */ BaseObserver),
2428
2434
  /* harmony export */ Column: () => (/* binding */ Column),
2429
2435
  /* harmony export */ ColumnType: () => (/* binding */ ColumnType),
@@ -2455,6 +2461,7 @@ __webpack_require__.r(__webpack_exports__);
2455
2461
  /* harmony export */ DiffTriggerOperation: () => (/* binding */ DiffTriggerOperation),
2456
2462
  /* harmony export */ DifferentialQueryProcessor: () => (/* binding */ DifferentialQueryProcessor),
2457
2463
  /* harmony export */ EMPTY_DIFFERENTIAL: () => (/* binding */ EMPTY_DIFFERENTIAL),
2464
+ /* harmony export */ EncodingType: () => (/* binding */ EncodingType),
2458
2465
  /* harmony export */ FalsyComparator: () => (/* binding */ FalsyComparator),
2459
2466
  /* harmony export */ FetchImplementationProvider: () => (/* binding */ FetchImplementationProvider),
2460
2467
  /* harmony export */ FetchStrategy: () => (/* binding */ FetchStrategy),
@@ -2483,12 +2490,14 @@ __webpack_require__.r(__webpack_exports__);
2483
2490
  /* harmony export */ SyncProgress: () => (/* binding */ SyncProgress),
2484
2491
  /* harmony export */ SyncStatus: () => (/* binding */ SyncStatus),
2485
2492
  /* harmony export */ SyncStreamConnectionMethod: () => (/* binding */ SyncStreamConnectionMethod),
2493
+ /* harmony export */ SyncingService: () => (/* binding */ SyncingService),
2486
2494
  /* harmony export */ Table: () => (/* binding */ Table),
2487
2495
  /* harmony export */ TableV2: () => (/* binding */ TableV2),
2488
2496
  /* harmony export */ TriggerManagerImpl: () => (/* binding */ TriggerManagerImpl),
2489
2497
  /* harmony export */ UpdateType: () => (/* binding */ UpdateType),
2490
2498
  /* harmony export */ UploadQueueStats: () => (/* binding */ UploadQueueStats),
2491
2499
  /* harmony export */ WatchedQueryListenerEvent: () => (/* binding */ WatchedQueryListenerEvent),
2500
+ /* harmony export */ attachmentFromSql: () => (/* binding */ attachmentFromSql),
2492
2501
  /* harmony export */ column: () => (/* binding */ column),
2493
2502
  /* harmony export */ compilableQueryWatch: () => (/* binding */ compilableQueryWatch),
2494
2503
  /* harmony export */ createBaseLogger: () => (/* binding */ createBaseLogger),
@@ -2507,6 +2516,7 @@ __webpack_require__.r(__webpack_exports__);
2507
2516
  /* harmony export */ isStreamingSyncCheckpointPartiallyComplete: () => (/* binding */ isStreamingSyncCheckpointPartiallyComplete),
2508
2517
  /* harmony export */ isStreamingSyncData: () => (/* binding */ isStreamingSyncData),
2509
2518
  /* harmony export */ isSyncNewCheckpointRequest: () => (/* binding */ isSyncNewCheckpointRequest),
2519
+ /* harmony export */ mutexRunExclusive: () => (/* binding */ mutexRunExclusive),
2510
2520
  /* harmony export */ parseQuery: () => (/* binding */ parseQuery),
2511
2521
  /* harmony export */ runOnSchemaChange: () => (/* binding */ runOnSchemaChange),
2512
2522
  /* harmony export */ sanitizeSQL: () => (/* binding */ sanitizeSQL),
@@ -2515,6 +2525,1224 @@ __webpack_require__.r(__webpack_exports__);
2515
2525
  /* harmony import */ var async_mutex__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! async-mutex */ "../../node_modules/.pnpm/async-mutex@0.5.0/node_modules/async-mutex/index.mjs");
2516
2526
 
2517
2527
 
2528
+ // https://www.sqlite.org/lang_expr.html#castexpr
2529
+ var ColumnType;
2530
+ (function (ColumnType) {
2531
+ ColumnType["TEXT"] = "TEXT";
2532
+ ColumnType["INTEGER"] = "INTEGER";
2533
+ ColumnType["REAL"] = "REAL";
2534
+ })(ColumnType || (ColumnType = {}));
2535
+ const text = {
2536
+ type: ColumnType.TEXT
2537
+ };
2538
+ const integer = {
2539
+ type: ColumnType.INTEGER
2540
+ };
2541
+ const real = {
2542
+ type: ColumnType.REAL
2543
+ };
2544
+ // powersync-sqlite-core limits the number of column per table to 1999, due to internal SQLite limits.
2545
+ // In earlier versions this was limited to 63.
2546
+ const MAX_AMOUNT_OF_COLUMNS = 1999;
2547
+ const column = {
2548
+ text,
2549
+ integer,
2550
+ real
2551
+ };
2552
+ class Column {
2553
+ options;
2554
+ constructor(options) {
2555
+ this.options = options;
2556
+ }
2557
+ get name() {
2558
+ return this.options.name;
2559
+ }
2560
+ get type() {
2561
+ return this.options.type;
2562
+ }
2563
+ toJSON() {
2564
+ return {
2565
+ name: this.name,
2566
+ type: this.type
2567
+ };
2568
+ }
2569
+ }
2570
+
2571
+ const DEFAULT_INDEX_COLUMN_OPTIONS = {
2572
+ ascending: true
2573
+ };
2574
+ class IndexedColumn {
2575
+ options;
2576
+ static createAscending(column) {
2577
+ return new IndexedColumn({
2578
+ name: column,
2579
+ ascending: true
2580
+ });
2581
+ }
2582
+ constructor(options) {
2583
+ this.options = { ...DEFAULT_INDEX_COLUMN_OPTIONS, ...options };
2584
+ }
2585
+ get name() {
2586
+ return this.options.name;
2587
+ }
2588
+ get ascending() {
2589
+ return this.options.ascending;
2590
+ }
2591
+ toJSON(table) {
2592
+ return {
2593
+ name: this.name,
2594
+ ascending: this.ascending,
2595
+ type: table.columns.find((column) => column.name === this.name)?.type ?? ColumnType.TEXT
2596
+ };
2597
+ }
2598
+ }
2599
+
2600
+ const DEFAULT_INDEX_OPTIONS = {
2601
+ columns: []
2602
+ };
2603
+ class Index {
2604
+ options;
2605
+ static createAscending(options, columnNames) {
2606
+ return new Index({
2607
+ ...options,
2608
+ columns: columnNames.map((name) => IndexedColumn.createAscending(name))
2609
+ });
2610
+ }
2611
+ constructor(options) {
2612
+ this.options = options;
2613
+ this.options = { ...DEFAULT_INDEX_OPTIONS, ...options };
2614
+ }
2615
+ get name() {
2616
+ return this.options.name;
2617
+ }
2618
+ get columns() {
2619
+ return this.options.columns ?? [];
2620
+ }
2621
+ toJSON(table) {
2622
+ return {
2623
+ name: this.name,
2624
+ columns: this.columns.map((c) => c.toJSON(table))
2625
+ };
2626
+ }
2627
+ }
2628
+
2629
+ const DEFAULT_TABLE_OPTIONS = {
2630
+ indexes: [],
2631
+ insertOnly: false,
2632
+ localOnly: false,
2633
+ trackPrevious: false,
2634
+ trackMetadata: false,
2635
+ ignoreEmptyUpdates: false
2636
+ };
2637
+ const InvalidSQLCharacters = /["'%,.#\s[\]]/;
2638
+ class Table {
2639
+ options;
2640
+ _mappedColumns;
2641
+ static createLocalOnly(options) {
2642
+ return new Table({ ...options, localOnly: true, insertOnly: false });
2643
+ }
2644
+ static createInsertOnly(options) {
2645
+ return new Table({ ...options, localOnly: false, insertOnly: true });
2646
+ }
2647
+ /**
2648
+ * Create a table.
2649
+ * @deprecated This was only only included for TableV2 and is no longer necessary.
2650
+ * Prefer to use new Table() directly.
2651
+ *
2652
+ * TODO remove in the next major release.
2653
+ */
2654
+ static createTable(name, table) {
2655
+ return new Table({
2656
+ name,
2657
+ columns: table.columns,
2658
+ indexes: table.indexes,
2659
+ localOnly: table.options.localOnly,
2660
+ insertOnly: table.options.insertOnly,
2661
+ viewName: table.options.viewName
2662
+ });
2663
+ }
2664
+ constructor(optionsOrColumns, v2Options) {
2665
+ if (this.isTableV1(optionsOrColumns)) {
2666
+ this.initTableV1(optionsOrColumns);
2667
+ }
2668
+ else {
2669
+ this.initTableV2(optionsOrColumns, v2Options);
2670
+ }
2671
+ }
2672
+ copyWithName(name) {
2673
+ return new Table({
2674
+ ...this.options,
2675
+ name
2676
+ });
2677
+ }
2678
+ isTableV1(arg) {
2679
+ return 'columns' in arg && Array.isArray(arg.columns);
2680
+ }
2681
+ initTableV1(options) {
2682
+ this.options = {
2683
+ ...options,
2684
+ indexes: options.indexes || []
2685
+ };
2686
+ this.applyDefaultOptions();
2687
+ }
2688
+ initTableV2(columns, options) {
2689
+ const convertedColumns = Object.entries(columns).map(([name, columnInfo]) => new Column({ name, type: columnInfo.type }));
2690
+ const convertedIndexes = Object.entries(options?.indexes ?? {}).map(([name, columnNames]) => new Index({
2691
+ name,
2692
+ columns: columnNames.map((name) => new IndexedColumn({
2693
+ name: name.replace(/^-/, ''),
2694
+ ascending: !name.startsWith('-')
2695
+ }))
2696
+ }));
2697
+ this.options = {
2698
+ name: '',
2699
+ columns: convertedColumns,
2700
+ indexes: convertedIndexes,
2701
+ viewName: options?.viewName,
2702
+ insertOnly: options?.insertOnly,
2703
+ localOnly: options?.localOnly,
2704
+ trackPrevious: options?.trackPrevious,
2705
+ trackMetadata: options?.trackMetadata,
2706
+ ignoreEmptyUpdates: options?.ignoreEmptyUpdates
2707
+ };
2708
+ this.applyDefaultOptions();
2709
+ this._mappedColumns = columns;
2710
+ }
2711
+ applyDefaultOptions() {
2712
+ this.options.insertOnly ??= DEFAULT_TABLE_OPTIONS.insertOnly;
2713
+ this.options.localOnly ??= DEFAULT_TABLE_OPTIONS.localOnly;
2714
+ this.options.trackPrevious ??= DEFAULT_TABLE_OPTIONS.trackPrevious;
2715
+ this.options.trackMetadata ??= DEFAULT_TABLE_OPTIONS.trackMetadata;
2716
+ this.options.ignoreEmptyUpdates ??= DEFAULT_TABLE_OPTIONS.ignoreEmptyUpdates;
2717
+ }
2718
+ get name() {
2719
+ return this.options.name;
2720
+ }
2721
+ get viewNameOverride() {
2722
+ return this.options.viewName;
2723
+ }
2724
+ get viewName() {
2725
+ return this.viewNameOverride ?? this.name;
2726
+ }
2727
+ get columns() {
2728
+ return this.options.columns;
2729
+ }
2730
+ get columnMap() {
2731
+ return (this._mappedColumns ??
2732
+ this.columns.reduce((hash, column) => {
2733
+ hash[column.name] = { type: column.type ?? ColumnType.TEXT };
2734
+ return hash;
2735
+ }, {}));
2736
+ }
2737
+ get indexes() {
2738
+ return this.options.indexes ?? [];
2739
+ }
2740
+ get localOnly() {
2741
+ return this.options.localOnly;
2742
+ }
2743
+ get insertOnly() {
2744
+ return this.options.insertOnly;
2745
+ }
2746
+ get trackPrevious() {
2747
+ return this.options.trackPrevious;
2748
+ }
2749
+ get trackMetadata() {
2750
+ return this.options.trackMetadata;
2751
+ }
2752
+ get ignoreEmptyUpdates() {
2753
+ return this.options.ignoreEmptyUpdates;
2754
+ }
2755
+ get internalName() {
2756
+ if (this.options.localOnly) {
2757
+ return `ps_data_local__${this.name}`;
2758
+ }
2759
+ return `ps_data__${this.name}`;
2760
+ }
2761
+ get validName() {
2762
+ if (InvalidSQLCharacters.test(this.name)) {
2763
+ return false;
2764
+ }
2765
+ if (this.viewNameOverride != null && InvalidSQLCharacters.test(this.viewNameOverride)) {
2766
+ return false;
2767
+ }
2768
+ return true;
2769
+ }
2770
+ validate() {
2771
+ if (InvalidSQLCharacters.test(this.name)) {
2772
+ throw new Error(`Invalid characters in table name: ${this.name}`);
2773
+ }
2774
+ if (this.viewNameOverride && InvalidSQLCharacters.test(this.viewNameOverride)) {
2775
+ throw new Error(`Invalid characters in view name: ${this.viewNameOverride}`);
2776
+ }
2777
+ if (this.columns.length > MAX_AMOUNT_OF_COLUMNS) {
2778
+ throw new Error(`Table has too many columns. The maximum number of columns is ${MAX_AMOUNT_OF_COLUMNS}.`);
2779
+ }
2780
+ if (this.trackMetadata && this.localOnly) {
2781
+ throw new Error(`Can't include metadata for local-only tables.`);
2782
+ }
2783
+ if (this.trackPrevious != false && this.localOnly) {
2784
+ throw new Error(`Can't include old values for local-only tables.`);
2785
+ }
2786
+ const columnNames = new Set();
2787
+ columnNames.add('id');
2788
+ for (const column of this.columns) {
2789
+ const { name: columnName } = column;
2790
+ if (column.name === 'id') {
2791
+ throw new Error(`An id column is automatically added, custom id columns are not supported`);
2792
+ }
2793
+ if (columnNames.has(columnName)) {
2794
+ throw new Error(`Duplicate column ${columnName}`);
2795
+ }
2796
+ if (InvalidSQLCharacters.test(columnName)) {
2797
+ throw new Error(`Invalid characters in column name: ${column.name}`);
2798
+ }
2799
+ columnNames.add(columnName);
2800
+ }
2801
+ const indexNames = new Set();
2802
+ for (const index of this.indexes) {
2803
+ if (indexNames.has(index.name)) {
2804
+ throw new Error(`Duplicate index ${index.name}`);
2805
+ }
2806
+ if (InvalidSQLCharacters.test(index.name)) {
2807
+ throw new Error(`Invalid characters in index name: ${index.name}`);
2808
+ }
2809
+ for (const column of index.columns) {
2810
+ if (!columnNames.has(column.name)) {
2811
+ throw new Error(`Column ${column.name} not found for index ${index.name}`);
2812
+ }
2813
+ }
2814
+ indexNames.add(index.name);
2815
+ }
2816
+ }
2817
+ toJSON() {
2818
+ const trackPrevious = this.trackPrevious;
2819
+ return {
2820
+ name: this.name,
2821
+ view_name: this.viewName,
2822
+ local_only: this.localOnly,
2823
+ insert_only: this.insertOnly,
2824
+ include_old: trackPrevious && (trackPrevious.columns ?? true),
2825
+ include_old_only_when_changed: typeof trackPrevious == 'object' && trackPrevious.onlyWhenChanged == true,
2826
+ include_metadata: this.trackMetadata,
2827
+ ignore_empty_update: this.ignoreEmptyUpdates,
2828
+ columns: this.columns.map((c) => c.toJSON()),
2829
+ indexes: this.indexes.map((e) => e.toJSON(this))
2830
+ };
2831
+ }
2832
+ }
2833
+
2834
+ const ATTACHMENT_TABLE = 'attachments';
2835
+ /**
2836
+ * Maps a database row to an AttachmentRecord.
2837
+ *
2838
+ * @param row - The database row object
2839
+ * @returns The corresponding AttachmentRecord
2840
+ *
2841
+ * @experimental
2842
+ */
2843
+ function attachmentFromSql(row) {
2844
+ return {
2845
+ id: row.id,
2846
+ filename: row.filename,
2847
+ localUri: row.local_uri,
2848
+ size: row.size,
2849
+ mediaType: row.media_type,
2850
+ timestamp: row.timestamp,
2851
+ metaData: row.meta_data,
2852
+ hasSynced: row.has_synced === 1,
2853
+ state: row.state
2854
+ };
2855
+ }
2856
+ /**
2857
+ * AttachmentState represents the current synchronization state of an attachment.
2858
+ *
2859
+ * @experimental
2860
+ */
2861
+ var AttachmentState;
2862
+ (function (AttachmentState) {
2863
+ AttachmentState[AttachmentState["QUEUED_UPLOAD"] = 0] = "QUEUED_UPLOAD";
2864
+ AttachmentState[AttachmentState["QUEUED_DOWNLOAD"] = 1] = "QUEUED_DOWNLOAD";
2865
+ AttachmentState[AttachmentState["QUEUED_DELETE"] = 2] = "QUEUED_DELETE";
2866
+ AttachmentState[AttachmentState["SYNCED"] = 3] = "SYNCED";
2867
+ AttachmentState[AttachmentState["ARCHIVED"] = 4] = "ARCHIVED"; // Attachment has been orphaned, i.e. the associated record has been deleted
2868
+ })(AttachmentState || (AttachmentState = {}));
2869
+ /**
2870
+ * AttachmentTable defines the schema for the attachment queue table.
2871
+ *
2872
+ * @internal
2873
+ */
2874
+ class AttachmentTable extends Table {
2875
+ constructor(options) {
2876
+ super({
2877
+ filename: column.text,
2878
+ local_uri: column.text,
2879
+ timestamp: column.integer,
2880
+ size: column.integer,
2881
+ media_type: column.text,
2882
+ state: column.integer, // Corresponds to AttachmentState
2883
+ has_synced: column.integer,
2884
+ meta_data: column.text
2885
+ }, {
2886
+ ...options,
2887
+ viewName: options?.viewName ?? ATTACHMENT_TABLE,
2888
+ localOnly: true,
2889
+ insertOnly: false
2890
+ });
2891
+ }
2892
+ }
2893
+
2894
+ /**
2895
+ * AttachmentContext provides database operations for managing attachment records.
2896
+ *
2897
+ * Provides methods to query, insert, update, and delete attachment records with
2898
+ * proper transaction management through PowerSync.
2899
+ *
2900
+ * @internal
2901
+ */
2902
+ class AttachmentContext {
2903
+ /** PowerSync database instance for executing queries */
2904
+ db;
2905
+ /** Name of the database table storing attachment records */
2906
+ tableName;
2907
+ /** Logger instance for diagnostic information */
2908
+ logger;
2909
+ /** Maximum number of archived attachments to keep before cleanup */
2910
+ archivedCacheLimit = 100;
2911
+ /**
2912
+ * Creates a new AttachmentContext instance.
2913
+ *
2914
+ * @param db - PowerSync database instance
2915
+ * @param tableName - Name of the table storing attachment records. Default: 'attachments'
2916
+ * @param logger - Logger instance for diagnostic output
2917
+ */
2918
+ constructor(db, tableName = 'attachments', logger, archivedCacheLimit) {
2919
+ this.db = db;
2920
+ this.tableName = tableName;
2921
+ this.logger = logger;
2922
+ this.archivedCacheLimit = archivedCacheLimit;
2923
+ }
2924
+ /**
2925
+ * Retrieves all active attachments that require synchronization.
2926
+ * Active attachments include those queued for upload, download, or delete.
2927
+ * Results are ordered by timestamp in ascending order.
2928
+ *
2929
+ * @returns Promise resolving to an array of active attachment records
2930
+ */
2931
+ async getActiveAttachments() {
2932
+ const attachments = await this.db.getAll(
2933
+ /* sql */
2934
+ `
2935
+ SELECT
2936
+ *
2937
+ FROM
2938
+ ${this.tableName}
2939
+ WHERE
2940
+ state = ?
2941
+ OR state = ?
2942
+ OR state = ?
2943
+ ORDER BY
2944
+ timestamp ASC
2945
+ `, [AttachmentState.QUEUED_UPLOAD, AttachmentState.QUEUED_DOWNLOAD, AttachmentState.QUEUED_DELETE]);
2946
+ return attachments.map(attachmentFromSql);
2947
+ }
2948
+ /**
2949
+ * Retrieves all archived attachments.
2950
+ *
2951
+ * Archived attachments are no longer referenced but haven't been permanently deleted.
2952
+ * These are candidates for cleanup operations to free up storage space.
2953
+ *
2954
+ * @returns Promise resolving to an array of archived attachment records
2955
+ */
2956
+ async getArchivedAttachments() {
2957
+ const attachments = await this.db.getAll(
2958
+ /* sql */
2959
+ `
2960
+ SELECT
2961
+ *
2962
+ FROM
2963
+ ${this.tableName}
2964
+ WHERE
2965
+ state = ?
2966
+ ORDER BY
2967
+ timestamp ASC
2968
+ `, [AttachmentState.ARCHIVED]);
2969
+ return attachments.map(attachmentFromSql);
2970
+ }
2971
+ /**
2972
+ * Retrieves all attachment records regardless of state.
2973
+ * Results are ordered by timestamp in ascending order.
2974
+ *
2975
+ * @returns Promise resolving to an array of all attachment records
2976
+ */
2977
+ async getAttachments() {
2978
+ const attachments = await this.db.getAll(
2979
+ /* sql */
2980
+ `
2981
+ SELECT
2982
+ *
2983
+ FROM
2984
+ ${this.tableName}
2985
+ ORDER BY
2986
+ timestamp ASC
2987
+ `, []);
2988
+ return attachments.map(attachmentFromSql);
2989
+ }
2990
+ /**
2991
+ * Inserts or updates an attachment record within an existing transaction.
2992
+ *
2993
+ * Performs an upsert operation (INSERT OR REPLACE). Must be called within
2994
+ * an active database transaction context.
2995
+ *
2996
+ * @param attachment - The attachment record to upsert
2997
+ * @param context - Active database transaction context
2998
+ */
2999
+ async upsertAttachment(attachment, context) {
3000
+ await context.execute(
3001
+ /* sql */
3002
+ `
3003
+ INSERT
3004
+ OR REPLACE INTO ${this.tableName} (
3005
+ id,
3006
+ filename,
3007
+ local_uri,
3008
+ size,
3009
+ media_type,
3010
+ timestamp,
3011
+ state,
3012
+ has_synced,
3013
+ meta_data
3014
+ )
3015
+ VALUES
3016
+ (?, ?, ?, ?, ?, ?, ?, ?, ?)
3017
+ `, [
3018
+ attachment.id,
3019
+ attachment.filename,
3020
+ attachment.localUri || null,
3021
+ attachment.size || null,
3022
+ attachment.mediaType || null,
3023
+ attachment.timestamp,
3024
+ attachment.state,
3025
+ attachment.hasSynced ? 1 : 0,
3026
+ attachment.metaData || null
3027
+ ]);
3028
+ }
3029
+ async getAttachment(id) {
3030
+ const attachment = await this.db.get(
3031
+ /* sql */
3032
+ `
3033
+ SELECT
3034
+ *
3035
+ FROM
3036
+ ${this.tableName}
3037
+ WHERE
3038
+ id = ?
3039
+ `, [id]);
3040
+ return attachment ? attachmentFromSql(attachment) : undefined;
3041
+ }
3042
+ /**
3043
+ * Permanently deletes an attachment record from the database.
3044
+ *
3045
+ * This operation removes the attachment record but does not delete
3046
+ * the associated local or remote files. File deletion should be handled
3047
+ * separately through the appropriate storage adapters.
3048
+ *
3049
+ * @param attachmentId - Unique identifier of the attachment to delete
3050
+ */
3051
+ async deleteAttachment(attachmentId) {
3052
+ await this.db.writeTransaction((tx) => tx.execute(
3053
+ /* sql */
3054
+ `
3055
+ DELETE FROM ${this.tableName}
3056
+ WHERE
3057
+ id = ?
3058
+ `, [attachmentId]));
3059
+ }
3060
+ async clearQueue() {
3061
+ await this.db.writeTransaction((tx) => tx.execute(/* sql */ ` DELETE FROM ${this.tableName} `));
3062
+ }
3063
+ async deleteArchivedAttachments(callback) {
3064
+ const limit = 1000;
3065
+ const results = await this.db.getAll(
3066
+ /* sql */
3067
+ `
3068
+ SELECT
3069
+ *
3070
+ FROM
3071
+ ${this.tableName}
3072
+ WHERE
3073
+ state = ?
3074
+ ORDER BY
3075
+ timestamp DESC
3076
+ LIMIT
3077
+ ?
3078
+ OFFSET
3079
+ ?
3080
+ `, [AttachmentState.ARCHIVED, limit, this.archivedCacheLimit]);
3081
+ const archivedAttachments = results.map(attachmentFromSql);
3082
+ if (archivedAttachments.length === 0)
3083
+ return false;
3084
+ await callback?.(archivedAttachments);
3085
+ this.logger.info(`Deleting ${archivedAttachments.length} archived attachments. Archived attachment exceeds cache archiveCacheLimit of ${this.archivedCacheLimit}.`);
3086
+ const ids = archivedAttachments.map((attachment) => attachment.id);
3087
+ await this.db.execute(
3088
+ /* sql */
3089
+ `
3090
+ DELETE FROM ${this.tableName}
3091
+ WHERE
3092
+ id IN (
3093
+ SELECT
3094
+ json_each.value
3095
+ FROM
3096
+ json_each (?)
3097
+ );
3098
+ `, [JSON.stringify(ids)]);
3099
+ this.logger.info(`Deleted ${archivedAttachments.length} archived attachments`);
3100
+ return archivedAttachments.length < limit;
3101
+ }
3102
+ /**
3103
+ * Saves multiple attachment records in a single transaction.
3104
+ *
3105
+ * All updates are saved in a single batch after processing.
3106
+ * If the attachments array is empty, no database operations are performed.
3107
+ *
3108
+ * @param attachments - Array of attachment records to save
3109
+ */
3110
+ async saveAttachments(attachments) {
3111
+ if (attachments.length === 0) {
3112
+ return;
3113
+ }
3114
+ await this.db.writeTransaction(async (tx) => {
3115
+ for (const attachment of attachments) {
3116
+ await this.upsertAttachment(attachment, tx);
3117
+ }
3118
+ });
3119
+ }
3120
+ }
3121
+
3122
+ var WatchedQueryListenerEvent;
3123
+ (function (WatchedQueryListenerEvent) {
3124
+ WatchedQueryListenerEvent["ON_DATA"] = "onData";
3125
+ WatchedQueryListenerEvent["ON_ERROR"] = "onError";
3126
+ WatchedQueryListenerEvent["ON_STATE_CHANGE"] = "onStateChange";
3127
+ WatchedQueryListenerEvent["SETTINGS_WILL_UPDATE"] = "settingsWillUpdate";
3128
+ WatchedQueryListenerEvent["CLOSED"] = "closed";
3129
+ })(WatchedQueryListenerEvent || (WatchedQueryListenerEvent = {}));
3130
+ const DEFAULT_WATCH_THROTTLE_MS = 30;
3131
+ const DEFAULT_WATCH_QUERY_OPTIONS = {
3132
+ throttleMs: DEFAULT_WATCH_THROTTLE_MS,
3133
+ reportFetching: true
3134
+ };
3135
+
3136
+ /**
3137
+ * Orchestrates attachment synchronization between local and remote storage.
3138
+ * Handles uploads, downloads, deletions, and state transitions.
3139
+ *
3140
+ * @internal
3141
+ */
3142
+ class SyncingService {
3143
+ attachmentService;
3144
+ localStorage;
3145
+ remoteStorage;
3146
+ logger;
3147
+ errorHandler;
3148
+ constructor(attachmentService, localStorage, remoteStorage, logger, errorHandler) {
3149
+ this.attachmentService = attachmentService;
3150
+ this.localStorage = localStorage;
3151
+ this.remoteStorage = remoteStorage;
3152
+ this.logger = logger;
3153
+ this.errorHandler = errorHandler;
3154
+ }
3155
+ /**
3156
+ * Processes attachments based on their state (upload, download, or delete).
3157
+ * All updates are saved in a single batch after processing.
3158
+ *
3159
+ * @param attachments - Array of attachment records to process
3160
+ * @param context - Attachment context for database operations
3161
+ * @returns Promise that resolves when all attachments have been processed and saved
3162
+ */
3163
+ async processAttachments(attachments, context) {
3164
+ const updatedAttachments = [];
3165
+ for (const attachment of attachments) {
3166
+ switch (attachment.state) {
3167
+ case AttachmentState.QUEUED_UPLOAD:
3168
+ const uploaded = await this.uploadAttachment(attachment);
3169
+ updatedAttachments.push(uploaded);
3170
+ break;
3171
+ case AttachmentState.QUEUED_DOWNLOAD:
3172
+ const downloaded = await this.downloadAttachment(attachment);
3173
+ updatedAttachments.push(downloaded);
3174
+ break;
3175
+ case AttachmentState.QUEUED_DELETE:
3176
+ const deleted = await this.deleteAttachment(attachment);
3177
+ updatedAttachments.push(deleted);
3178
+ break;
3179
+ }
3180
+ }
3181
+ await context.saveAttachments(updatedAttachments);
3182
+ }
3183
+ /**
3184
+ * Uploads an attachment from local storage to remote storage.
3185
+ * On success, marks as SYNCED. On failure, defers to error handler or archives.
3186
+ *
3187
+ * @param attachment - The attachment record to upload
3188
+ * @returns Updated attachment record with new state
3189
+ * @throws Error if the attachment has no localUri
3190
+ */
3191
+ async uploadAttachment(attachment) {
3192
+ this.logger.info(`Uploading attachment ${attachment.filename}`);
3193
+ try {
3194
+ if (attachment.localUri == null) {
3195
+ throw new Error(`No localUri for attachment ${attachment.id}`);
3196
+ }
3197
+ const fileBlob = await this.localStorage.readFile(attachment.localUri);
3198
+ await this.remoteStorage.uploadFile(fileBlob, attachment);
3199
+ return {
3200
+ ...attachment,
3201
+ state: AttachmentState.SYNCED,
3202
+ hasSynced: true
3203
+ };
3204
+ }
3205
+ catch (error) {
3206
+ const shouldRetry = (await this.errorHandler?.onUploadError(attachment, error)) ?? true;
3207
+ if (!shouldRetry) {
3208
+ return {
3209
+ ...attachment,
3210
+ state: AttachmentState.ARCHIVED
3211
+ };
3212
+ }
3213
+ return attachment;
3214
+ }
3215
+ }
3216
+ /**
3217
+ * Downloads an attachment from remote storage to local storage.
3218
+ * Retrieves the file, converts to base64, and saves locally.
3219
+ * On success, marks as SYNCED. On failure, defers to error handler or archives.
3220
+ *
3221
+ * @param attachment - The attachment record to download
3222
+ * @returns Updated attachment record with local URI and new state
3223
+ */
3224
+ async downloadAttachment(attachment) {
3225
+ this.logger.info(`Downloading attachment ${attachment.filename}`);
3226
+ try {
3227
+ const fileData = await this.remoteStorage.downloadFile(attachment);
3228
+ const localUri = this.localStorage.getLocalUri(attachment.filename);
3229
+ await this.localStorage.saveFile(localUri, fileData);
3230
+ return {
3231
+ ...attachment,
3232
+ state: AttachmentState.SYNCED,
3233
+ localUri: localUri,
3234
+ hasSynced: true
3235
+ };
3236
+ }
3237
+ catch (error) {
3238
+ const shouldRetry = (await this.errorHandler?.onDownloadError(attachment, error)) ?? true;
3239
+ if (!shouldRetry) {
3240
+ return {
3241
+ ...attachment,
3242
+ state: AttachmentState.ARCHIVED
3243
+ };
3244
+ }
3245
+ return attachment;
3246
+ }
3247
+ }
3248
+ /**
3249
+ * Deletes an attachment from both remote and local storage.
3250
+ * Removes the remote file, local file (if exists), and the attachment record.
3251
+ * On failure, defers to error handler or archives.
3252
+ *
3253
+ * @param attachment - The attachment record to delete
3254
+ * @returns Updated attachment record
3255
+ */
3256
+ async deleteAttachment(attachment) {
3257
+ try {
3258
+ await this.remoteStorage.deleteFile(attachment);
3259
+ if (attachment.localUri) {
3260
+ await this.localStorage.deleteFile(attachment.localUri);
3261
+ }
3262
+ await this.attachmentService.withContext(async (ctx) => {
3263
+ await ctx.deleteAttachment(attachment.id);
3264
+ });
3265
+ return {
3266
+ ...attachment,
3267
+ state: AttachmentState.ARCHIVED
3268
+ };
3269
+ }
3270
+ catch (error) {
3271
+ const shouldRetry = (await this.errorHandler?.onDeleteError(attachment, error)) ?? true;
3272
+ if (!shouldRetry) {
3273
+ return {
3274
+ ...attachment,
3275
+ state: AttachmentState.ARCHIVED
3276
+ };
3277
+ }
3278
+ return attachment;
3279
+ }
3280
+ }
3281
+ /**
3282
+ * Performs cleanup of archived attachments by removing their local files and records.
3283
+ * Errors during local file deletion are logged but do not prevent record deletion.
3284
+ */
3285
+ async deleteArchivedAttachments(context) {
3286
+ return await context.deleteArchivedAttachments(async (archivedAttachments) => {
3287
+ for (const attachment of archivedAttachments) {
3288
+ if (attachment.localUri) {
3289
+ try {
3290
+ await this.localStorage.deleteFile(attachment.localUri);
3291
+ }
3292
+ catch (error) {
3293
+ this.logger.error('Error deleting local file for archived attachment', error);
3294
+ }
3295
+ }
3296
+ }
3297
+ });
3298
+ }
3299
+ }
3300
+
3301
+ /**
3302
+ * Wrapper for async-mutex runExclusive, which allows for a timeout on each exclusive lock.
3303
+ */
3304
+ async function mutexRunExclusive(mutex, callback, options) {
3305
+ return new Promise((resolve, reject) => {
3306
+ const timeout = options?.timeoutMs;
3307
+ let timedOut = false;
3308
+ const timeoutId = timeout
3309
+ ? setTimeout(() => {
3310
+ timedOut = true;
3311
+ reject(new Error('Timeout waiting for lock'));
3312
+ }, timeout)
3313
+ : undefined;
3314
+ mutex.runExclusive(async () => {
3315
+ if (timeoutId) {
3316
+ clearTimeout(timeoutId);
3317
+ }
3318
+ if (timedOut)
3319
+ return;
3320
+ try {
3321
+ resolve(await callback());
3322
+ }
3323
+ catch (ex) {
3324
+ reject(ex);
3325
+ }
3326
+ });
3327
+ });
3328
+ }
3329
+
3330
+ /**
3331
+ * Service for querying and watching attachment records in the database.
3332
+ *
3333
+ * @internal
3334
+ */
3335
+ class AttachmentService {
3336
+ db;
3337
+ logger;
3338
+ tableName;
3339
+ mutex = new async_mutex__WEBPACK_IMPORTED_MODULE_0__.Mutex();
3340
+ context;
3341
+ constructor(db, logger, tableName = 'attachments', archivedCacheLimit = 100) {
3342
+ this.db = db;
3343
+ this.logger = logger;
3344
+ this.tableName = tableName;
3345
+ this.context = new AttachmentContext(db, tableName, logger, archivedCacheLimit);
3346
+ }
3347
+ /**
3348
+ * Creates a differential watch query for active attachments requiring synchronization.
3349
+ * @returns Watch query that emits changes for queued uploads, downloads, and deletes
3350
+ */
3351
+ watchActiveAttachments({ throttleMs } = {}) {
3352
+ this.logger.info('Watching active attachments...');
3353
+ const watch = this.db
3354
+ .query({
3355
+ sql: /* sql */ `
3356
+ SELECT
3357
+ *
3358
+ FROM
3359
+ ${this.tableName}
3360
+ WHERE
3361
+ state = ?
3362
+ OR state = ?
3363
+ OR state = ?
3364
+ ORDER BY
3365
+ timestamp ASC
3366
+ `,
3367
+ parameters: [AttachmentState.QUEUED_UPLOAD, AttachmentState.QUEUED_DOWNLOAD, AttachmentState.QUEUED_DELETE]
3368
+ })
3369
+ .differentialWatch({ throttleMs });
3370
+ return watch;
3371
+ }
3372
+ /**
3373
+ * Executes a callback with exclusive access to the attachment context.
3374
+ */
3375
+ async withContext(callback) {
3376
+ return mutexRunExclusive(this.mutex, async () => {
3377
+ return callback(this.context);
3378
+ });
3379
+ }
3380
+ }
3381
+
3382
+ /**
3383
+ * AttachmentQueue manages the lifecycle and synchronization of attachments
3384
+ * between local and remote storage.
3385
+ * Provides automatic synchronization, upload/download queuing, attachment monitoring,
3386
+ * verification and repair of local files, and cleanup of archived attachments.
3387
+ *
3388
+ * @experimental
3389
+ * @alpha This is currently experimental and may change without a major version bump.
3390
+ */
3391
+ class AttachmentQueue {
3392
+ /** Timer for periodic synchronization operations */
3393
+ periodicSyncTimer;
3394
+ /** Service for synchronizing attachments between local and remote storage */
3395
+ syncingService;
3396
+ /** Adapter for local file storage operations */
3397
+ localStorage;
3398
+ /** Adapter for remote file storage operations */
3399
+ remoteStorage;
3400
+ /**
3401
+ * Callback function to watch for changes in attachment references in your data model.
3402
+ *
3403
+ * This should be implemented by the user of AttachmentQueue to monitor changes in your application's
3404
+ * data that reference attachments. When attachments are added, removed, or modified,
3405
+ * this callback should trigger the onUpdate function with the current set of attachments.
3406
+ */
3407
+ watchAttachments;
3408
+ /** Name of the database table storing attachment records */
3409
+ tableName;
3410
+ /** Logger instance for diagnostic information */
3411
+ logger;
3412
+ /** Interval in milliseconds between periodic sync operations. Default: 30000 (30 seconds) */
3413
+ syncIntervalMs = 30 * 1000;
3414
+ /** Duration in milliseconds to throttle sync operations */
3415
+ syncThrottleDuration;
3416
+ /** Whether to automatically download remote attachments. Default: true */
3417
+ downloadAttachments = true;
3418
+ /** Maximum number of archived attachments to keep before cleanup. Default: 100 */
3419
+ archivedCacheLimit;
3420
+ /** Service for managing attachment-related database operations */
3421
+ attachmentService;
3422
+ /** PowerSync database instance */
3423
+ db;
3424
+ /** Cleanup function for status change listener */
3425
+ statusListenerDispose;
3426
+ watchActiveAttachments;
3427
+ watchAttachmentsAbortController;
3428
+ /**
3429
+ * Creates a new AttachmentQueue instance.
3430
+ *
3431
+ * @param options - Configuration options
3432
+ * @param options.db - PowerSync database instance
3433
+ * @param options.remoteStorage - Remote storage adapter for upload/download operations
3434
+ * @param options.localStorage - Local storage adapter for file persistence
3435
+ * @param options.watchAttachments - Callback for monitoring attachment changes in your data model
3436
+ * @param options.tableName - Name of the table to store attachment records. Default: 'ps_attachment_queue'
3437
+ * @param options.logger - Logger instance. Defaults to db.logger
3438
+ * @param options.syncIntervalMs - Interval between automatic syncs in milliseconds. Default: 30000
3439
+ * @param options.syncThrottleDuration - Throttle duration for sync operations in milliseconds. Default: 1000
3440
+ * @param options.downloadAttachments - Whether to automatically download remote attachments. Default: true
3441
+ * @param options.archivedCacheLimit - Maximum archived attachments before cleanup. Default: 100
3442
+ */
3443
+ constructor({ db, localStorage, remoteStorage, watchAttachments, logger, tableName = ATTACHMENT_TABLE, syncIntervalMs = 30 * 1000, syncThrottleDuration = DEFAULT_WATCH_THROTTLE_MS, downloadAttachments = true, archivedCacheLimit = 100, errorHandler }) {
3444
+ this.db = db;
3445
+ this.remoteStorage = remoteStorage;
3446
+ this.localStorage = localStorage;
3447
+ this.watchAttachments = watchAttachments;
3448
+ this.tableName = tableName;
3449
+ this.syncIntervalMs = syncIntervalMs;
3450
+ this.syncThrottleDuration = syncThrottleDuration;
3451
+ this.archivedCacheLimit = archivedCacheLimit;
3452
+ this.downloadAttachments = downloadAttachments;
3453
+ this.logger = logger ?? db.logger;
3454
+ this.attachmentService = new AttachmentService(db, this.logger, tableName, archivedCacheLimit);
3455
+ this.syncingService = new SyncingService(this.attachmentService, localStorage, remoteStorage, this.logger, errorHandler);
3456
+ }
3457
+ /**
3458
+ * Generates a new attachment ID using a SQLite UUID function.
3459
+ *
3460
+ * @returns Promise resolving to the new attachment ID
3461
+ */
3462
+ async generateAttachmentId() {
3463
+ return this.db.get('SELECT uuid() as id').then((row) => row.id);
3464
+ }
3465
+ /**
3466
+ * Starts the attachment synchronization process.
3467
+ *
3468
+ * This method:
3469
+ * - Stops any existing sync operations
3470
+ * - Sets up periodic synchronization based on syncIntervalMs
3471
+ * - Registers listeners for active attachment changes
3472
+ * - Processes watched attachments to queue uploads/downloads
3473
+ * - Handles state transitions for archived and new attachments
3474
+ */
3475
+ async startSync() {
3476
+ await this.stopSync();
3477
+ this.watchActiveAttachments = this.attachmentService.watchActiveAttachments({
3478
+ throttleMs: this.syncThrottleDuration
3479
+ });
3480
+ // immediately invoke the sync storage to initialize local storage
3481
+ await this.localStorage.initialize();
3482
+ await this.verifyAttachments();
3483
+ // Sync storage periodically
3484
+ this.periodicSyncTimer = setInterval(async () => {
3485
+ await this.syncStorage();
3486
+ }, this.syncIntervalMs);
3487
+ // Sync storage when there is a change in active attachments
3488
+ this.watchActiveAttachments.registerListener({
3489
+ onDiff: async () => {
3490
+ await this.syncStorage();
3491
+ }
3492
+ });
3493
+ this.statusListenerDispose = this.db.registerListener({
3494
+ statusChanged: (status) => {
3495
+ if (status.connected) {
3496
+ // Device came online, process attachments immediately
3497
+ this.syncStorage().catch((error) => {
3498
+ this.logger.error('Error syncing storage on connection:', error);
3499
+ });
3500
+ }
3501
+ }
3502
+ });
3503
+ this.watchAttachmentsAbortController = new AbortController();
3504
+ const signal = this.watchAttachmentsAbortController.signal;
3505
+ // Process attachments when there is a change in watched attachments
3506
+ this.watchAttachments(async (watchedAttachments) => {
3507
+ // Skip processing if sync has been stopped
3508
+ if (signal.aborted) {
3509
+ return;
3510
+ }
3511
+ await this.attachmentService.withContext(async (ctx) => {
3512
+ // Need to get all the attachments which are tracked in the DB.
3513
+ // We might need to restore an archived attachment.
3514
+ const currentAttachments = await ctx.getAttachments();
3515
+ const attachmentUpdates = [];
3516
+ for (const watchedAttachment of watchedAttachments) {
3517
+ const existingQueueItem = currentAttachments.find((a) => a.id === watchedAttachment.id);
3518
+ if (!existingQueueItem) {
3519
+ // Item is watched but not in the queue yet. Need to add it.
3520
+ if (!this.downloadAttachments) {
3521
+ continue;
3522
+ }
3523
+ const filename = watchedAttachment.filename ?? `${watchedAttachment.id}.${watchedAttachment.fileExtension}`;
3524
+ attachmentUpdates.push({
3525
+ id: watchedAttachment.id,
3526
+ filename,
3527
+ state: AttachmentState.QUEUED_DOWNLOAD,
3528
+ hasSynced: false,
3529
+ metaData: watchedAttachment.metaData,
3530
+ timestamp: new Date().getTime()
3531
+ });
3532
+ continue;
3533
+ }
3534
+ if (existingQueueItem.state === AttachmentState.ARCHIVED) {
3535
+ // The attachment is present again. Need to queue it for sync.
3536
+ // We might be able to optimize this in future
3537
+ if (existingQueueItem.hasSynced === true) {
3538
+ // No remote action required, we can restore the record (avoids deletion)
3539
+ attachmentUpdates.push({
3540
+ ...existingQueueItem,
3541
+ state: AttachmentState.SYNCED
3542
+ });
3543
+ }
3544
+ else {
3545
+ // The localURI should be set if the record was meant to be uploaded
3546
+ // and hasSynced is false then
3547
+ // it must be an upload operation
3548
+ const newState = existingQueueItem.localUri == null ? AttachmentState.QUEUED_DOWNLOAD : AttachmentState.QUEUED_UPLOAD;
3549
+ attachmentUpdates.push({
3550
+ ...existingQueueItem,
3551
+ state: newState
3552
+ });
3553
+ }
3554
+ }
3555
+ }
3556
+ for (const attachment of currentAttachments) {
3557
+ const notInWatchedItems = watchedAttachments.find((i) => i.id === attachment.id) == null;
3558
+ if (notInWatchedItems) {
3559
+ switch (attachment.state) {
3560
+ case AttachmentState.QUEUED_DELETE:
3561
+ case AttachmentState.QUEUED_UPLOAD:
3562
+ // Only archive if it has synced
3563
+ if (attachment.hasSynced === true) {
3564
+ attachmentUpdates.push({
3565
+ ...attachment,
3566
+ state: AttachmentState.ARCHIVED
3567
+ });
3568
+ }
3569
+ break;
3570
+ default:
3571
+ // Archive other states such as QUEUED_DOWNLOAD
3572
+ attachmentUpdates.push({
3573
+ ...attachment,
3574
+ state: AttachmentState.ARCHIVED
3575
+ });
3576
+ }
3577
+ }
3578
+ }
3579
+ if (attachmentUpdates.length > 0) {
3580
+ await ctx.saveAttachments(attachmentUpdates);
3581
+ }
3582
+ });
3583
+ }, signal);
3584
+ }
3585
+ /**
3586
+ * Synchronizes all active attachments between local and remote storage.
3587
+ *
3588
+ * This is called automatically at regular intervals when sync is started,
3589
+ * but can also be called manually to trigger an immediate sync.
3590
+ */
3591
+ async syncStorage() {
3592
+ await this.attachmentService.withContext(async (ctx) => {
3593
+ const activeAttachments = await ctx.getActiveAttachments();
3594
+ await this.localStorage.initialize();
3595
+ await this.syncingService.processAttachments(activeAttachments, ctx);
3596
+ await this.syncingService.deleteArchivedAttachments(ctx);
3597
+ });
3598
+ }
3599
+ /**
3600
+ * Stops the attachment synchronization process.
3601
+ *
3602
+ * Clears the periodic sync timer and closes all active attachment watchers.
3603
+ */
3604
+ async stopSync() {
3605
+ clearInterval(this.periodicSyncTimer);
3606
+ this.periodicSyncTimer = undefined;
3607
+ if (this.watchActiveAttachments)
3608
+ await this.watchActiveAttachments.close();
3609
+ if (this.watchAttachmentsAbortController) {
3610
+ this.watchAttachmentsAbortController.abort();
3611
+ }
3612
+ if (this.statusListenerDispose) {
3613
+ this.statusListenerDispose();
3614
+ this.statusListenerDispose = undefined;
3615
+ }
3616
+ }
3617
+ /**
3618
+ * Saves a file to local storage and queues it for upload to remote storage.
3619
+ *
3620
+ * @param options - File save options
3621
+ * @param options.data - The file data as ArrayBuffer, Blob, or base64 string
3622
+ * @param options.fileExtension - File extension (e.g., 'jpg', 'pdf')
3623
+ * @param options.mediaType - MIME type of the file (e.g., 'image/jpeg')
3624
+ * @param options.metaData - Optional metadata to associate with the attachment
3625
+ * @param options.id - Optional custom ID. If not provided, a UUID will be generated
3626
+ * @param options.updateHook - Optional callback to execute additional database operations
3627
+ * within the same transaction as the attachment creation
3628
+ * @returns Promise resolving to the created attachment record
3629
+ */
3630
+ async saveFile({ data, fileExtension, mediaType, metaData, id, updateHook }) {
3631
+ const resolvedId = id ?? (await this.generateAttachmentId());
3632
+ const filename = `${resolvedId}.${fileExtension}`;
3633
+ const localUri = this.localStorage.getLocalUri(filename);
3634
+ const size = await this.localStorage.saveFile(localUri, data);
3635
+ const attachment = {
3636
+ id: resolvedId,
3637
+ filename,
3638
+ mediaType,
3639
+ localUri,
3640
+ state: AttachmentState.QUEUED_UPLOAD,
3641
+ hasSynced: false,
3642
+ size,
3643
+ timestamp: new Date().getTime(),
3644
+ metaData
3645
+ };
3646
+ await this.attachmentService.withContext(async (ctx) => {
3647
+ await ctx.db.writeTransaction(async (tx) => {
3648
+ await updateHook?.(tx, attachment);
3649
+ await ctx.upsertAttachment(attachment, tx);
3650
+ });
3651
+ });
3652
+ return attachment;
3653
+ }
3654
+ async deleteFile({ id, updateHook }) {
3655
+ await this.attachmentService.withContext(async (ctx) => {
3656
+ const attachment = await ctx.getAttachment(id);
3657
+ if (!attachment) {
3658
+ throw new Error(`Attachment with id ${id} not found`);
3659
+ }
3660
+ await ctx.db.writeTransaction(async (tx) => {
3661
+ await updateHook?.(tx, attachment);
3662
+ await ctx.upsertAttachment({
3663
+ ...attachment,
3664
+ state: AttachmentState.QUEUED_DELETE,
3665
+ hasSynced: false
3666
+ }, tx);
3667
+ });
3668
+ });
3669
+ }
3670
+ async expireCache() {
3671
+ let isDone = false;
3672
+ while (!isDone) {
3673
+ await this.attachmentService.withContext(async (ctx) => {
3674
+ isDone = await this.syncingService.deleteArchivedAttachments(ctx);
3675
+ });
3676
+ }
3677
+ }
3678
+ async clearQueue() {
3679
+ await this.attachmentService.withContext(async (ctx) => {
3680
+ await ctx.clearQueue();
3681
+ });
3682
+ await this.localStorage.clear();
3683
+ }
3684
+ /**
3685
+ * Verifies the integrity of all attachment records and repairs inconsistencies.
3686
+ *
3687
+ * This method checks each attachment record against the local filesystem and:
3688
+ * - Updates localUri if the file exists at a different path
3689
+ * - Archives attachments with missing local files that haven't been uploaded
3690
+ * - Requeues synced attachments for download if their local files are missing
3691
+ */
3692
+ async verifyAttachments() {
3693
+ await this.attachmentService.withContext(async (ctx) => {
3694
+ const attachments = await ctx.getAttachments();
3695
+ const updates = [];
3696
+ for (const attachment of attachments) {
3697
+ if (attachment.localUri == null) {
3698
+ continue;
3699
+ }
3700
+ const exists = await this.localStorage.fileExists(attachment.localUri);
3701
+ if (exists) {
3702
+ // The file exists, this is correct
3703
+ continue;
3704
+ }
3705
+ const newLocalUri = this.localStorage.getLocalUri(attachment.filename);
3706
+ const newExists = await this.localStorage.fileExists(newLocalUri);
3707
+ if (newExists) {
3708
+ // The file exists locally but the localUri is broken, we update it.
3709
+ updates.push({
3710
+ ...attachment,
3711
+ localUri: newLocalUri
3712
+ });
3713
+ }
3714
+ else {
3715
+ // the file doesn't exist locally.
3716
+ if (attachment.state === AttachmentState.SYNCED) {
3717
+ // the file has been successfully synced to remote storage but is missing
3718
+ // we download it again
3719
+ updates.push({
3720
+ ...attachment,
3721
+ state: AttachmentState.QUEUED_DOWNLOAD,
3722
+ localUri: undefined
3723
+ });
3724
+ }
3725
+ else {
3726
+ // the file wasn't successfully synced to remote storage, we archive it
3727
+ updates.push({
3728
+ ...attachment,
3729
+ state: AttachmentState.ARCHIVED,
3730
+ localUri: undefined // Clears the value
3731
+ });
3732
+ }
3733
+ }
3734
+ }
3735
+ await ctx.saveAttachments(updates);
3736
+ });
3737
+ }
3738
+ }
3739
+
3740
+ var EncodingType;
3741
+ (function (EncodingType) {
3742
+ EncodingType["UTF8"] = "utf8";
3743
+ EncodingType["Base64"] = "base64";
3744
+ })(EncodingType || (EncodingType = {}));
3745
+
2518
3746
  function getDefaultExportFromCjs (x) {
2519
3747
  return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x;
2520
3748
  }
@@ -3831,20 +5059,6 @@ class MetaBaseObserver extends BaseObserver {
3831
5059
  }
3832
5060
  }
3833
5061
 
3834
- var WatchedQueryListenerEvent;
3835
- (function (WatchedQueryListenerEvent) {
3836
- WatchedQueryListenerEvent["ON_DATA"] = "onData";
3837
- WatchedQueryListenerEvent["ON_ERROR"] = "onError";
3838
- WatchedQueryListenerEvent["ON_STATE_CHANGE"] = "onStateChange";
3839
- WatchedQueryListenerEvent["SETTINGS_WILL_UPDATE"] = "settingsWillUpdate";
3840
- WatchedQueryListenerEvent["CLOSED"] = "closed";
3841
- })(WatchedQueryListenerEvent || (WatchedQueryListenerEvent = {}));
3842
- const DEFAULT_WATCH_THROTTLE_MS = 30;
3843
- const DEFAULT_WATCH_QUERY_OPTIONS = {
3844
- throttleMs: DEFAULT_WATCH_THROTTLE_MS,
3845
- reportFetching: true
3846
- };
3847
-
3848
5062
  /**
3849
5063
  * Performs underlying watching and yields a stream of results.
3850
5064
  * @internal
@@ -11757,7 +12971,7 @@ function requireDist () {
11757
12971
 
11758
12972
  var distExports = requireDist();
11759
12973
 
11760
- var version = "1.46.0";
12974
+ var version = "1.47.0";
11761
12975
  var PACKAGE = {
11762
12976
  version: version};
11763
12977
 
@@ -15391,151 +16605,50 @@ class SqliteBucketStorage extends BaseObserver {
15391
16605
  ]);
15392
16606
  return r != 0;
15393
16607
  }
15394
- async migrateToFixedSubkeys() {
15395
- await this.writeTransaction(async (tx) => {
15396
- await tx.execute('UPDATE ps_oplog SET key = powersync_remove_duplicate_key_encoding(key);');
15397
- await tx.execute('INSERT OR REPLACE INTO ps_kv (key, value) VALUES (?, ?);', [
15398
- SqliteBucketStorage._subkeyMigrationKey,
15399
- '1'
15400
- ]);
15401
- });
15402
- }
15403
- static _subkeyMigrationKey = 'powersync_js_migrated_subkeys';
15404
- }
15405
- function hasMatchingPriority(priority, bucket) {
15406
- return bucket.priority != null && bucket.priority <= priority;
15407
- }
15408
-
15409
- // TODO JSON
15410
- class SyncDataBatch {
15411
- buckets;
15412
- static fromJSON(json) {
15413
- return new SyncDataBatch(json.buckets.map((bucket) => SyncDataBucket.fromRow(bucket)));
15414
- }
15415
- constructor(buckets) {
15416
- this.buckets = buckets;
15417
- }
15418
- }
15419
-
15420
- /**
15421
- * Thrown when an underlying database connection is closed.
15422
- * This is particularly relevant when worker connections are marked as closed while
15423
- * operations are still in progress.
15424
- */
15425
- class ConnectionClosedError extends Error {
15426
- static NAME = 'ConnectionClosedError';
15427
- static MATCHES(input) {
15428
- /**
15429
- * If there are weird package issues which cause multiple versions of classes to be present, the instanceof
15430
- * check might fail. This also performs a failsafe check.
15431
- * This might also happen if the Error is serialized and parsed over a bridging channel like a MessagePort.
15432
- */
15433
- return (input instanceof ConnectionClosedError || (input instanceof Error && input.name == ConnectionClosedError.NAME));
15434
- }
15435
- constructor(message) {
15436
- super(message);
15437
- this.name = ConnectionClosedError.NAME;
15438
- }
15439
- }
15440
-
15441
- // https://www.sqlite.org/lang_expr.html#castexpr
15442
- var ColumnType;
15443
- (function (ColumnType) {
15444
- ColumnType["TEXT"] = "TEXT";
15445
- ColumnType["INTEGER"] = "INTEGER";
15446
- ColumnType["REAL"] = "REAL";
15447
- })(ColumnType || (ColumnType = {}));
15448
- const text = {
15449
- type: ColumnType.TEXT
15450
- };
15451
- const integer = {
15452
- type: ColumnType.INTEGER
15453
- };
15454
- const real = {
15455
- type: ColumnType.REAL
15456
- };
15457
- // powersync-sqlite-core limits the number of column per table to 1999, due to internal SQLite limits.
15458
- // In earlier versions this was limited to 63.
15459
- const MAX_AMOUNT_OF_COLUMNS = 1999;
15460
- const column = {
15461
- text,
15462
- integer,
15463
- real
15464
- };
15465
- class Column {
15466
- options;
15467
- constructor(options) {
15468
- this.options = options;
15469
- }
15470
- get name() {
15471
- return this.options.name;
15472
- }
15473
- get type() {
15474
- return this.options.type;
15475
- }
15476
- toJSON() {
15477
- return {
15478
- name: this.name,
15479
- type: this.type
15480
- };
15481
- }
15482
- }
15483
-
15484
- const DEFAULT_INDEX_COLUMN_OPTIONS = {
15485
- ascending: true
15486
- };
15487
- class IndexedColumn {
15488
- options;
15489
- static createAscending(column) {
15490
- return new IndexedColumn({
15491
- name: column,
15492
- ascending: true
15493
- });
15494
- }
15495
- constructor(options) {
15496
- this.options = { ...DEFAULT_INDEX_COLUMN_OPTIONS, ...options };
15497
- }
15498
- get name() {
15499
- return this.options.name;
15500
- }
15501
- get ascending() {
15502
- return this.options.ascending;
15503
- }
15504
- toJSON(table) {
15505
- return {
15506
- name: this.name,
15507
- ascending: this.ascending,
15508
- type: table.columns.find((column) => column.name === this.name)?.type ?? ColumnType.TEXT
15509
- };
15510
- }
15511
- }
15512
-
15513
- const DEFAULT_INDEX_OPTIONS = {
15514
- columns: []
15515
- };
15516
- class Index {
15517
- options;
15518
- static createAscending(options, columnNames) {
15519
- return new Index({
15520
- ...options,
15521
- columns: columnNames.map((name) => IndexedColumn.createAscending(name))
16608
+ async migrateToFixedSubkeys() {
16609
+ await this.writeTransaction(async (tx) => {
16610
+ await tx.execute('UPDATE ps_oplog SET key = powersync_remove_duplicate_key_encoding(key);');
16611
+ await tx.execute('INSERT OR REPLACE INTO ps_kv (key, value) VALUES (?, ?);', [
16612
+ SqliteBucketStorage._subkeyMigrationKey,
16613
+ '1'
16614
+ ]);
15522
16615
  });
15523
16616
  }
15524
- constructor(options) {
15525
- this.options = options;
15526
- this.options = { ...DEFAULT_INDEX_OPTIONS, ...options };
16617
+ static _subkeyMigrationKey = 'powersync_js_migrated_subkeys';
16618
+ }
16619
+ function hasMatchingPriority(priority, bucket) {
16620
+ return bucket.priority != null && bucket.priority <= priority;
16621
+ }
16622
+
16623
+ // TODO JSON
16624
+ class SyncDataBatch {
16625
+ buckets;
16626
+ static fromJSON(json) {
16627
+ return new SyncDataBatch(json.buckets.map((bucket) => SyncDataBucket.fromRow(bucket)));
15527
16628
  }
15528
- get name() {
15529
- return this.options.name;
16629
+ constructor(buckets) {
16630
+ this.buckets = buckets;
15530
16631
  }
15531
- get columns() {
15532
- return this.options.columns ?? [];
16632
+ }
16633
+
16634
+ /**
16635
+ * Thrown when an underlying database connection is closed.
16636
+ * This is particularly relevant when worker connections are marked as closed while
16637
+ * operations are still in progress.
16638
+ */
16639
+ class ConnectionClosedError extends Error {
16640
+ static NAME = 'ConnectionClosedError';
16641
+ static MATCHES(input) {
16642
+ /**
16643
+ * If there are weird package issues which cause multiple versions of classes to be present, the instanceof
16644
+ * check might fail. This also performs a failsafe check.
16645
+ * This might also happen if the Error is serialized and parsed over a bridging channel like a MessagePort.
16646
+ */
16647
+ return (input instanceof ConnectionClosedError || (input instanceof Error && input.name == ConnectionClosedError.NAME));
15533
16648
  }
15534
- toJSON(table) {
15535
- return {
15536
- name: this.name,
15537
- columns: this.columns.map((c) => c.toJSON(table))
15538
- };
16649
+ constructor(message) {
16650
+ super(message);
16651
+ this.name = ConnectionClosedError.NAME;
15539
16652
  }
15540
16653
  }
15541
16654
 
@@ -15632,211 +16745,6 @@ class Schema {
15632
16745
  }
15633
16746
  }
15634
16747
 
15635
- const DEFAULT_TABLE_OPTIONS = {
15636
- indexes: [],
15637
- insertOnly: false,
15638
- localOnly: false,
15639
- trackPrevious: false,
15640
- trackMetadata: false,
15641
- ignoreEmptyUpdates: false
15642
- };
15643
- const InvalidSQLCharacters = /["'%,.#\s[\]]/;
15644
- class Table {
15645
- options;
15646
- _mappedColumns;
15647
- static createLocalOnly(options) {
15648
- return new Table({ ...options, localOnly: true, insertOnly: false });
15649
- }
15650
- static createInsertOnly(options) {
15651
- return new Table({ ...options, localOnly: false, insertOnly: true });
15652
- }
15653
- /**
15654
- * Create a table.
15655
- * @deprecated This was only only included for TableV2 and is no longer necessary.
15656
- * Prefer to use new Table() directly.
15657
- *
15658
- * TODO remove in the next major release.
15659
- */
15660
- static createTable(name, table) {
15661
- return new Table({
15662
- name,
15663
- columns: table.columns,
15664
- indexes: table.indexes,
15665
- localOnly: table.options.localOnly,
15666
- insertOnly: table.options.insertOnly,
15667
- viewName: table.options.viewName
15668
- });
15669
- }
15670
- constructor(optionsOrColumns, v2Options) {
15671
- if (this.isTableV1(optionsOrColumns)) {
15672
- this.initTableV1(optionsOrColumns);
15673
- }
15674
- else {
15675
- this.initTableV2(optionsOrColumns, v2Options);
15676
- }
15677
- }
15678
- copyWithName(name) {
15679
- return new Table({
15680
- ...this.options,
15681
- name
15682
- });
15683
- }
15684
- isTableV1(arg) {
15685
- return 'columns' in arg && Array.isArray(arg.columns);
15686
- }
15687
- initTableV1(options) {
15688
- this.options = {
15689
- ...options,
15690
- indexes: options.indexes || []
15691
- };
15692
- this.applyDefaultOptions();
15693
- }
15694
- initTableV2(columns, options) {
15695
- const convertedColumns = Object.entries(columns).map(([name, columnInfo]) => new Column({ name, type: columnInfo.type }));
15696
- const convertedIndexes = Object.entries(options?.indexes ?? {}).map(([name, columnNames]) => new Index({
15697
- name,
15698
- columns: columnNames.map((name) => new IndexedColumn({
15699
- name: name.replace(/^-/, ''),
15700
- ascending: !name.startsWith('-')
15701
- }))
15702
- }));
15703
- this.options = {
15704
- name: '',
15705
- columns: convertedColumns,
15706
- indexes: convertedIndexes,
15707
- viewName: options?.viewName,
15708
- insertOnly: options?.insertOnly,
15709
- localOnly: options?.localOnly,
15710
- trackPrevious: options?.trackPrevious,
15711
- trackMetadata: options?.trackMetadata,
15712
- ignoreEmptyUpdates: options?.ignoreEmptyUpdates
15713
- };
15714
- this.applyDefaultOptions();
15715
- this._mappedColumns = columns;
15716
- }
15717
- applyDefaultOptions() {
15718
- this.options.insertOnly ??= DEFAULT_TABLE_OPTIONS.insertOnly;
15719
- this.options.localOnly ??= DEFAULT_TABLE_OPTIONS.localOnly;
15720
- this.options.trackPrevious ??= DEFAULT_TABLE_OPTIONS.trackPrevious;
15721
- this.options.trackMetadata ??= DEFAULT_TABLE_OPTIONS.trackMetadata;
15722
- this.options.ignoreEmptyUpdates ??= DEFAULT_TABLE_OPTIONS.ignoreEmptyUpdates;
15723
- }
15724
- get name() {
15725
- return this.options.name;
15726
- }
15727
- get viewNameOverride() {
15728
- return this.options.viewName;
15729
- }
15730
- get viewName() {
15731
- return this.viewNameOverride ?? this.name;
15732
- }
15733
- get columns() {
15734
- return this.options.columns;
15735
- }
15736
- get columnMap() {
15737
- return (this._mappedColumns ??
15738
- this.columns.reduce((hash, column) => {
15739
- hash[column.name] = { type: column.type ?? ColumnType.TEXT };
15740
- return hash;
15741
- }, {}));
15742
- }
15743
- get indexes() {
15744
- return this.options.indexes ?? [];
15745
- }
15746
- get localOnly() {
15747
- return this.options.localOnly;
15748
- }
15749
- get insertOnly() {
15750
- return this.options.insertOnly;
15751
- }
15752
- get trackPrevious() {
15753
- return this.options.trackPrevious;
15754
- }
15755
- get trackMetadata() {
15756
- return this.options.trackMetadata;
15757
- }
15758
- get ignoreEmptyUpdates() {
15759
- return this.options.ignoreEmptyUpdates;
15760
- }
15761
- get internalName() {
15762
- if (this.options.localOnly) {
15763
- return `ps_data_local__${this.name}`;
15764
- }
15765
- return `ps_data__${this.name}`;
15766
- }
15767
- get validName() {
15768
- if (InvalidSQLCharacters.test(this.name)) {
15769
- return false;
15770
- }
15771
- if (this.viewNameOverride != null && InvalidSQLCharacters.test(this.viewNameOverride)) {
15772
- return false;
15773
- }
15774
- return true;
15775
- }
15776
- validate() {
15777
- if (InvalidSQLCharacters.test(this.name)) {
15778
- throw new Error(`Invalid characters in table name: ${this.name}`);
15779
- }
15780
- if (this.viewNameOverride && InvalidSQLCharacters.test(this.viewNameOverride)) {
15781
- throw new Error(`Invalid characters in view name: ${this.viewNameOverride}`);
15782
- }
15783
- if (this.columns.length > MAX_AMOUNT_OF_COLUMNS) {
15784
- throw new Error(`Table has too many columns. The maximum number of columns is ${MAX_AMOUNT_OF_COLUMNS}.`);
15785
- }
15786
- if (this.trackMetadata && this.localOnly) {
15787
- throw new Error(`Can't include metadata for local-only tables.`);
15788
- }
15789
- if (this.trackPrevious != false && this.localOnly) {
15790
- throw new Error(`Can't include old values for local-only tables.`);
15791
- }
15792
- const columnNames = new Set();
15793
- columnNames.add('id');
15794
- for (const column of this.columns) {
15795
- const { name: columnName } = column;
15796
- if (column.name === 'id') {
15797
- throw new Error(`An id column is automatically added, custom id columns are not supported`);
15798
- }
15799
- if (columnNames.has(columnName)) {
15800
- throw new Error(`Duplicate column ${columnName}`);
15801
- }
15802
- if (InvalidSQLCharacters.test(columnName)) {
15803
- throw new Error(`Invalid characters in column name: ${column.name}`);
15804
- }
15805
- columnNames.add(columnName);
15806
- }
15807
- const indexNames = new Set();
15808
- for (const index of this.indexes) {
15809
- if (indexNames.has(index.name)) {
15810
- throw new Error(`Duplicate index ${index.name}`);
15811
- }
15812
- if (InvalidSQLCharacters.test(index.name)) {
15813
- throw new Error(`Invalid characters in index name: ${index.name}`);
15814
- }
15815
- for (const column of index.columns) {
15816
- if (!columnNames.has(column.name)) {
15817
- throw new Error(`Column ${column.name} not found for index ${index.name}`);
15818
- }
15819
- }
15820
- indexNames.add(index.name);
15821
- }
15822
- }
15823
- toJSON() {
15824
- const trackPrevious = this.trackPrevious;
15825
- return {
15826
- name: this.name,
15827
- view_name: this.viewName,
15828
- local_only: this.localOnly,
15829
- insert_only: this.insertOnly,
15830
- include_old: trackPrevious && (trackPrevious.columns ?? true),
15831
- include_old_only_when_changed: typeof trackPrevious == 'object' && trackPrevious.onlyWhenChanged == true,
15832
- include_metadata: this.trackMetadata,
15833
- ignore_empty_update: this.ignoreEmptyUpdates,
15834
- columns: this.columns.map((c) => c.toJSON()),
15835
- indexes: this.indexes.map((e) => e.toJSON(this))
15836
- };
15837
- }
15838
- }
15839
-
15840
16748
  /**
15841
16749
  Generate a new table from the columns and indexes
15842
16750
  @deprecated You should use {@link Table} instead as it now allows TableV2 syntax.