All files / src/lib/powersync connector.ts

87.5% Statements 28/32
83.33% Branches 20/24
100% Functions 7/7
89.65% Lines 26/29

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87              3x 2x 2x       10x           4x   4x 2x     2x 2x       2x                                   6x 5x 3x 3x     3x               6x 6x   6x 6x   6x 5x 5x   1x 1x 1x                 5x      
import type { PowerSyncBackendConnector, AbstractPowerSyncDatabase } from '@powersync/web';
import type { SupabaseClient } from '@supabase/supabase-js';
import { PUBLIC_POWERSYNC_URL } from '$env/static/public';
 
// PostgreSQL error class prefixes that indicate a permanent data problem.
// Operations with these errors are discarded rather than retried.
function isDiscardableError(code: string): boolean {
  if (code === '42501') return true; // insufficient privilege (RLS violation)
  const prefix = code.slice(0, 2);
  return prefix === '22' || prefix === '23'; // data exception / integrity constraint violation
}
 
export class SupabaseConnector implements PowerSyncBackendConnector {
  constructor(private readonly supabase: SupabaseClient) {}
 
  async fetchCredentials() {
    const {
      data: { session },
      error,
    } = await this.supabase.auth.getSession();
 
    if (!session || error) {
      throw new Error('No active session');
    }
 
    const endpoint = PUBLIC_POWERSYNC_URL;
    Iif (!endpoint) {
      throw new Error('PUBLIC_POWERSYNC_URL is not configured');
    }
 
    return {
      endpoint,
      token: session.access_token,
      expiresAt: session.expires_at ? new Date(session.expires_at * 1000) : undefined,
    };
  }
 
  /**
   * Execute a single Supabase CRUD operation and handle errors uniformly.
   * Discardable errors (RLS, constraint, data exceptions) are logged and swallowed.
   * All other errors propagate so PowerSync can retry the batch.
   */
  private async executeOp(
    table: string,
    opType: string,
    id: string,
    fn: () => PromiseLike<{ error: { code: string; message: string } | null }>,
  ): Promise<void> {
    const { error } = await fn();
    if (!error) return;
    Eif (isDiscardableError(error.code)) {
      console.warn(
        `[PowerSync] Discarding ${opType} on ${table}/${id} — permanent error ${error.code}: ${error.message}`,
      );
      return; // fall through; batch.complete() will be called by uploadData
    }
    throw new Error(
      `[PowerSync] Upload failed for ${opType} on ${table}/${id}: ${error.message} (${error.code})`,
    );
  }
 
  async uploadData(database: AbstractPowerSyncDatabase): Promise<void> {
    const batch = await database.getCrudBatch(200);
    Iif (!batch) return;
 
    for (const op of batch.crud) {
      const { table, op: opType, id, opData: record } = op;
 
      if (opType === 'PUT' || opType === 'PATCH') {
        await this.executeOp(table, opType, id, () =>
          this.supabase.from(table).upsert({ id, ...record }),
        );
      } else if (opType === 'DELETE') {
        await this.executeOp(table, 'DELETE', id, () =>
          this.supabase.from(table).delete().eq('id', id),
        );
      } else E{
        console.warn(`[PowerSync] Unknown operation type: ${opType} — skipping`);
      }
    }
 
    // Network errors and unexpected failures propagate before reaching here,
    // so PowerSync retries the batch. Only completed batches reach this point.
    await batch.complete();
  }
}