@intentsolutionsio/supabase-pack 1.0.0 → 1.0.3
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/LICENSE +1 -1
- package/README.md +73 -47
- package/package.json +4 -4
- package/skills/supabase-advanced-troubleshooting/SKILL.md +404 -200
- package/skills/supabase-advanced-troubleshooting/references/errors.md +11 -0
- package/skills/supabase-advanced-troubleshooting/references/evidence-collection-framework.md +34 -0
- package/skills/supabase-advanced-troubleshooting/references/examples.md +11 -0
- package/skills/supabase-advanced-troubleshooting/references/rls-edge-functions-realtime.md +363 -0
- package/skills/supabase-advanced-troubleshooting/references/systematic-isolation.md +56 -0
- package/skills/supabase-advanced-troubleshooting/references/timing-analysis.md +35 -0
- package/skills/supabase-architecture-variants/SKILL.md +395 -216
- package/skills/supabase-architecture-variants/references/errors.md +11 -0
- package/skills/supabase-architecture-variants/references/examples.md +12 -0
- package/skills/supabase-architecture-variants/references/serverless-and-multi-tenant.md +251 -0
- package/skills/supabase-architecture-variants/references/variant-a-monolith-(simple).md +44 -0
- package/skills/supabase-architecture-variants/references/variant-b-service-layer-(moderate).md +72 -0
- package/skills/supabase-architecture-variants/references/variant-c-microservice-(complex).md +81 -0
- package/skills/supabase-auth-storage-realtime-core/SKILL.md +471 -37
- package/skills/supabase-ci-integration/SKILL.md +315 -67
- package/skills/supabase-ci-integration/references/errors.md +10 -0
- package/skills/supabase-ci-integration/references/examples.md +36 -0
- package/skills/supabase-ci-integration/references/implementation.md +54 -0
- package/skills/supabase-common-errors/SKILL.md +320 -62
- package/skills/supabase-common-errors/references/errors.md +53 -0
- package/skills/supabase-common-errors/references/examples.md +23 -0
- package/skills/supabase-cost-tuning/SKILL.md +365 -131
- package/skills/supabase-cost-tuning/references/cost-estimation.md +34 -0
- package/skills/supabase-cost-tuning/references/cost-reduction-strategies.md +40 -0
- package/skills/supabase-cost-tuning/references/errors.md +11 -0
- package/skills/supabase-cost-tuning/references/examples.md +15 -0
- package/skills/supabase-data-handling/SKILL.md +378 -145
- package/skills/supabase-data-handling/references/errors.md +11 -0
- package/skills/supabase-data-handling/references/examples.md +27 -0
- package/skills/supabase-data-handling/references/implementation.md +223 -0
- package/skills/supabase-data-handling/references/retention-and-backup.md +221 -0
- package/skills/supabase-debug-bundle/SKILL.md +267 -73
- package/skills/supabase-debug-bundle/references/errors.md +12 -0
- package/skills/supabase-debug-bundle/references/examples.md +24 -0
- package/skills/supabase-debug-bundle/references/implementation.md +54 -0
- package/skills/supabase-deploy-integration/SKILL.md +258 -147
- package/skills/supabase-deploy-integration/references/errors.md +11 -0
- package/skills/supabase-deploy-integration/references/examples.md +21 -0
- package/skills/supabase-deploy-integration/references/google-cloud-run.md +36 -0
- package/skills/supabase-deploy-integration/references/vercel-deployment.md +35 -0
- package/skills/supabase-enterprise-rbac/SKILL.md +327 -160
- package/skills/supabase-enterprise-rbac/references/api-scoping-and-enforcement.md +255 -0
- package/skills/supabase-enterprise-rbac/references/errors.md +11 -0
- package/skills/supabase-enterprise-rbac/references/examples.md +12 -0
- package/skills/supabase-enterprise-rbac/references/role-implementation.md +33 -0
- package/skills/supabase-enterprise-rbac/references/sso-integration.md +35 -0
- package/skills/supabase-hello-world/SKILL.md +160 -54
- package/skills/supabase-incident-runbook/SKILL.md +453 -131
- package/skills/supabase-incident-runbook/references/errors.md +11 -0
- package/skills/supabase-incident-runbook/references/examples.md +10 -0
- package/skills/supabase-incident-runbook/references/immediate-actions-by-error-type.md +41 -0
- package/skills/supabase-install-auth/SKILL.md +186 -50
- package/skills/supabase-install-auth/references/examples.md +102 -0
- package/skills/supabase-known-pitfalls/SKILL.md +411 -241
- package/skills/supabase-known-pitfalls/references/errors.md +11 -0
- package/skills/supabase-known-pitfalls/references/examples.md +12 -0
- package/skills/supabase-load-scale/SKILL.md +346 -217
- package/skills/supabase-load-scale/references/capacity-planning.md +47 -0
- package/skills/supabase-load-scale/references/errors.md +11 -0
- package/skills/supabase-load-scale/references/examples.md +26 -0
- package/skills/supabase-load-scale/references/load-testing-with-k6.md +59 -0
- package/skills/supabase-load-scale/references/scaling-patterns.md +65 -0
- package/skills/supabase-load-scale/references/table-partitioning.md +263 -0
- package/skills/supabase-local-dev-loop/SKILL.md +272 -73
- package/skills/supabase-local-dev-loop/references/errors.md +11 -0
- package/skills/supabase-local-dev-loop/references/examples.md +21 -0
- package/skills/supabase-local-dev-loop/references/implementation.md +60 -0
- package/skills/supabase-migration-deep-dive/SKILL.md +338 -177
- package/skills/supabase-migration-deep-dive/references/backfill-versioning-rollback.md +258 -0
- package/skills/supabase-migration-deep-dive/references/errors.md +11 -0
- package/skills/supabase-migration-deep-dive/references/examples.md +12 -0
- package/skills/supabase-migration-deep-dive/references/implementation-plan.md +80 -0
- package/skills/supabase-migration-deep-dive/references/pre-migration-assessment.md +39 -0
- package/skills/supabase-multi-env-setup/SKILL.md +393 -152
- package/skills/supabase-multi-env-setup/references/configuration-structure.md +59 -0
- package/skills/supabase-multi-env-setup/references/errors.md +11 -0
- package/skills/supabase-multi-env-setup/references/examples.md +11 -0
- package/skills/supabase-observability/SKILL.md +318 -196
- package/skills/supabase-observability/references/alert-configuration.md +40 -0
- package/skills/supabase-observability/references/errors.md +11 -0
- package/skills/supabase-observability/references/examples.md +13 -0
- package/skills/supabase-observability/references/metrics-collection.md +65 -0
- package/skills/supabase-performance-tuning/SKILL.md +304 -160
- package/skills/supabase-performance-tuning/references/caching-strategy.md +49 -0
- package/skills/supabase-performance-tuning/references/errors.md +11 -0
- package/skills/supabase-performance-tuning/references/examples.md +13 -0
- package/skills/supabase-policy-guardrails/SKILL.md +248 -221
- package/skills/supabase-policy-guardrails/references/ci-cost-security.md +484 -0
- package/skills/supabase-policy-guardrails/references/errors.md +11 -0
- package/skills/supabase-policy-guardrails/references/eslint-rules.md +46 -0
- package/skills/supabase-policy-guardrails/references/examples.md +10 -0
- package/skills/supabase-prod-checklist/SKILL.md +474 -84
- package/skills/supabase-prod-checklist/references/errors.md +63 -0
- package/skills/supabase-prod-checklist/references/examples.md +153 -0
- package/skills/supabase-prod-checklist/references/implementation.md +113 -0
- package/skills/supabase-rate-limits/SKILL.md +311 -98
- package/skills/supabase-rate-limits/references/errors.md +11 -0
- package/skills/supabase-rate-limits/references/examples.md +46 -0
- package/skills/supabase-rate-limits/references/implementation.md +66 -0
- package/skills/supabase-reference-architecture/SKILL.md +249 -182
- package/skills/supabase-reference-architecture/references/errors.md +29 -0
- package/skills/supabase-reference-architecture/references/examples.md +116 -0
- package/skills/supabase-reference-architecture/references/key-components.md +244 -0
- package/skills/supabase-reference-architecture/references/project-structure.md +109 -0
- package/skills/supabase-reliability-patterns/SKILL.md +229 -234
- package/skills/supabase-reliability-patterns/references/circuit-breaker.md +36 -0
- package/skills/supabase-reliability-patterns/references/dead-letter-queue.md +48 -0
- package/skills/supabase-reliability-patterns/references/errors.md +11 -0
- package/skills/supabase-reliability-patterns/references/examples.md +11 -0
- package/skills/supabase-reliability-patterns/references/idempotency-keys.md +36 -0
- package/skills/supabase-reliability-patterns/references/offline-degradation-health-dualwrite.md +489 -0
- package/skills/supabase-schema-from-requirements/SKILL.md +373 -34
- package/skills/supabase-sdk-patterns/SKILL.md +388 -99
- package/skills/supabase-sdk-patterns/references/errors.md +11 -0
- package/skills/supabase-sdk-patterns/references/examples.md +45 -0
- package/skills/supabase-sdk-patterns/references/implementation.md +67 -0
- package/skills/supabase-security-basics/SKILL.md +282 -102
- package/skills/supabase-security-basics/references/errors.md +10 -0
- package/skills/supabase-security-basics/references/examples.md +70 -0
- package/skills/supabase-security-basics/references/implementation.md +39 -0
- package/skills/supabase-upgrade-migration/SKILL.md +248 -66
- package/skills/supabase-upgrade-migration/references/errors.md +10 -0
- package/skills/supabase-upgrade-migration/references/examples.md +51 -0
- package/skills/supabase-upgrade-migration/references/implementation.md +29 -0
- package/skills/supabase-webhooks-events/SKILL.md +412 -138
- package/skills/supabase-webhooks-events/references/errors.md +55 -0
- package/skills/supabase-webhooks-events/references/event-handler-pattern.md +106 -0
- package/skills/supabase-webhooks-events/references/examples.md +133 -0
- package/skills/supabase-webhooks-events/references/signature-verification.md +165 -0
package/skills/supabase-reliability-patterns/references/offline-degradation-health-dualwrite.md
ADDED
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
## Offline Queue, Graceful Degradation, Health Checks, and Dual-Write
|
|
2
|
+
|
|
3
|
+
### Offline Queue with IndexedDB (Browser)
|
|
4
|
+
|
|
5
|
+
```typescript
|
|
6
|
+
// lib/offline-queue.ts
|
|
7
|
+
import { createClient } from '@supabase/supabase-js'
|
|
8
|
+
import type { Database } from './database.types'
|
|
9
|
+
|
|
10
|
+
interface QueuedOperation {
|
|
11
|
+
id: string
|
|
12
|
+
table: string
|
|
13
|
+
method: 'insert' | 'update' | 'delete'
|
|
14
|
+
payload: any
|
|
15
|
+
createdAt: number
|
|
16
|
+
retries: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
class OfflineQueue {
|
|
20
|
+
private db: IDBDatabase | null = null
|
|
21
|
+
private readonly STORE = 'pending_operations'
|
|
22
|
+
private processing = false
|
|
23
|
+
|
|
24
|
+
async init() {
|
|
25
|
+
return new Promise<void>((resolve, reject) => {
|
|
26
|
+
const request = indexedDB.open('supabase_offline_queue', 1)
|
|
27
|
+
request.onupgradeneeded = () => {
|
|
28
|
+
const db = request.result
|
|
29
|
+
if (!db.objectStoreNames.contains(this.STORE)) {
|
|
30
|
+
db.createObjectStore(this.STORE, { keyPath: 'id' })
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
request.onsuccess = () => { this.db = request.result; resolve() }
|
|
34
|
+
request.onerror = () => reject(request.error)
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async enqueue(op: Omit<QueuedOperation, 'id' | 'createdAt' | 'retries'>) {
|
|
39
|
+
if (!this.db) await this.init()
|
|
40
|
+
|
|
41
|
+
const entry: QueuedOperation = {
|
|
42
|
+
...op,
|
|
43
|
+
id: crypto.randomUUID(),
|
|
44
|
+
createdAt: Date.now(),
|
|
45
|
+
retries: 0,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const tx = this.db!.transaction(this.STORE, 'readwrite')
|
|
49
|
+
tx.objectStore(this.STORE).add(entry)
|
|
50
|
+
await new Promise(resolve => { tx.oncomplete = resolve })
|
|
51
|
+
|
|
52
|
+
console.log(`[OfflineQueue] Enqueued ${op.method} on ${op.table}`)
|
|
53
|
+
return entry.id
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async flush(supabase: ReturnType<typeof createClient<Database>>) {
|
|
57
|
+
if (this.processing || !this.db) return
|
|
58
|
+
this.processing = true
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const tx = this.db.transaction(this.STORE, 'readonly')
|
|
62
|
+
const store = tx.objectStore(this.STORE)
|
|
63
|
+
const request = store.getAll()
|
|
64
|
+
const items: QueuedOperation[] = await new Promise(resolve => {
|
|
65
|
+
request.onsuccess = () => resolve(request.result)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
for (const item of items.sort((a, b) => a.createdAt - b.createdAt)) {
|
|
69
|
+
try {
|
|
70
|
+
let result: any
|
|
71
|
+
|
|
72
|
+
if (item.method === 'insert') {
|
|
73
|
+
result = await supabase.from(item.table).insert(item.payload).select()
|
|
74
|
+
} else if (item.method === 'update') {
|
|
75
|
+
const { id, ...updates } = item.payload
|
|
76
|
+
result = await supabase.from(item.table).update(updates).eq('id', id)
|
|
77
|
+
} else if (item.method === 'delete') {
|
|
78
|
+
result = await supabase.from(item.table).delete().eq('id', item.payload.id)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (result?.error) throw result.error
|
|
82
|
+
|
|
83
|
+
// Remove from queue on success
|
|
84
|
+
const deleteTx = this.db!.transaction(this.STORE, 'readwrite')
|
|
85
|
+
deleteTx.objectStore(this.STORE).delete(item.id)
|
|
86
|
+
console.log(`[OfflineQueue] Flushed ${item.method} on ${item.table}`)
|
|
87
|
+
} catch (error) {
|
|
88
|
+
console.warn(`[OfflineQueue] Failed to flush ${item.id}:`, error)
|
|
89
|
+
// Increment retry count; give up after 10 attempts
|
|
90
|
+
if (item.retries >= 10) {
|
|
91
|
+
const deleteTx = this.db!.transaction(this.STORE, 'readwrite')
|
|
92
|
+
deleteTx.objectStore(this.STORE).delete(item.id)
|
|
93
|
+
console.error(`[OfflineQueue] Gave up on ${item.id} after 10 retries`)
|
|
94
|
+
} else {
|
|
95
|
+
const updateTx = this.db!.transaction(this.STORE, 'readwrite')
|
|
96
|
+
updateTx.objectStore(this.STORE).put({ ...item, retries: item.retries + 1 })
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
} finally {
|
|
101
|
+
this.processing = false
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
get pendingCount(): Promise<number> {
|
|
106
|
+
if (!this.db) return Promise.resolve(0)
|
|
107
|
+
const tx = this.db.transaction(this.STORE, 'readonly')
|
|
108
|
+
const request = tx.objectStore(this.STORE).count()
|
|
109
|
+
return new Promise(resolve => { request.onsuccess = () => resolve(request.result) })
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const offlineQueue = new OfflineQueue()
|
|
114
|
+
|
|
115
|
+
// Auto-flush when back online
|
|
116
|
+
if (typeof window !== 'undefined') {
|
|
117
|
+
window.addEventListener('online', async () => {
|
|
118
|
+
const supabase = createClient<Database>(
|
|
119
|
+
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
120
|
+
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
|
121
|
+
)
|
|
122
|
+
await offlineQueue.flush(supabase)
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Graceful Degradation with Cached Fallbacks
|
|
128
|
+
|
|
129
|
+
```typescript
|
|
130
|
+
// lib/degradation.ts
|
|
131
|
+
import { createClient } from '@supabase/supabase-js'
|
|
132
|
+
import type { Database } from './database.types'
|
|
133
|
+
|
|
134
|
+
interface DegradedResponse<T> {
|
|
135
|
+
data: T
|
|
136
|
+
degraded: boolean
|
|
137
|
+
source: 'live' | 'cache' | 'fallback'
|
|
138
|
+
cachedAt?: number
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const cache = new Map<string, { data: any; timestamp: number }>()
|
|
142
|
+
|
|
143
|
+
export async function withDegradation<T>(
|
|
144
|
+
cacheKey: string,
|
|
145
|
+
liveFn: () => Promise<{ data: T | null; error: any }>,
|
|
146
|
+
fallbackValue: T,
|
|
147
|
+
cacheTtlMs = 5 * 60 * 1000 // 5 min default
|
|
148
|
+
): Promise<DegradedResponse<T>> {
|
|
149
|
+
// Try live data first
|
|
150
|
+
try {
|
|
151
|
+
const { data, error } = await liveFn()
|
|
152
|
+
if (!error && data !== null) {
|
|
153
|
+
// Update cache on success
|
|
154
|
+
cache.set(cacheKey, { data, timestamp: Date.now() })
|
|
155
|
+
return { data, degraded: false, source: 'live' }
|
|
156
|
+
}
|
|
157
|
+
// Fall through to cache on error
|
|
158
|
+
if (error) console.warn(`[Degradation] Live fetch failed: ${error.message}`)
|
|
159
|
+
} catch (err) {
|
|
160
|
+
console.warn('[Degradation] Live fetch threw:', err)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Try cached data
|
|
164
|
+
const cached = cache.get(cacheKey)
|
|
165
|
+
if (cached && Date.now() - cached.timestamp < cacheTtlMs) {
|
|
166
|
+
return {
|
|
167
|
+
data: cached.data as T,
|
|
168
|
+
degraded: true,
|
|
169
|
+
source: 'cache',
|
|
170
|
+
cachedAt: cached.timestamp,
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Last resort: static fallback
|
|
175
|
+
return { data: fallbackValue, degraded: true, source: 'fallback' }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Usage
|
|
179
|
+
const supabase = createClient<Database>(
|
|
180
|
+
process.env.SUPABASE_URL!,
|
|
181
|
+
process.env.SUPABASE_ANON_KEY!
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
async function getProducts(categoryId: string) {
|
|
185
|
+
const result = await withDegradation(
|
|
186
|
+
`products:${categoryId}`,
|
|
187
|
+
() => supabase
|
|
188
|
+
.from('products')
|
|
189
|
+
.select('id, name, price, image_url')
|
|
190
|
+
.eq('category_id', categoryId)
|
|
191
|
+
.order('name'),
|
|
192
|
+
[] // fallback: empty product list
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
if (result.degraded) {
|
|
196
|
+
console.log(`Serving ${result.source} data for products`)
|
|
197
|
+
// Show banner: "Some data may be stale"
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return result
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
## Step 3 — Health Checks and Dual-Write for Critical Data
|
|
205
|
+
|
|
206
|
+
### Health Check Endpoint
|
|
207
|
+
|
|
208
|
+
```typescript
|
|
209
|
+
// api/health.ts (Next.js API route or Express handler)
|
|
210
|
+
import { createClient } from '@supabase/supabase-js'
|
|
211
|
+
|
|
212
|
+
interface HealthStatus {
|
|
213
|
+
status: 'healthy' | 'degraded' | 'unhealthy'
|
|
214
|
+
services: {
|
|
215
|
+
database: { ok: boolean; latencyMs: number }
|
|
216
|
+
auth: { ok: boolean; latencyMs: number }
|
|
217
|
+
storage: { ok: boolean; latencyMs: number }
|
|
218
|
+
}
|
|
219
|
+
timestamp: string
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export async function checkHealth(): Promise<HealthStatus> {
|
|
223
|
+
const supabase = createClient(
|
|
224
|
+
process.env.SUPABASE_URL!,
|
|
225
|
+
process.env.SUPABASE_SERVICE_ROLE_KEY!,
|
|
226
|
+
{ auth: { autoRefreshToken: false, persistSession: false } }
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
const checks = await Promise.allSettled([
|
|
230
|
+
// Database health: simple query
|
|
231
|
+
(async () => {
|
|
232
|
+
const start = Date.now()
|
|
233
|
+
const { error } = await supabase.from('health_checks').select('id').limit(1)
|
|
234
|
+
return { ok: !error, latencyMs: Date.now() - start }
|
|
235
|
+
})(),
|
|
236
|
+
|
|
237
|
+
// Auth health: verify service key works
|
|
238
|
+
(async () => {
|
|
239
|
+
const start = Date.now()
|
|
240
|
+
const { error } = await supabase.auth.admin.listUsers({ perPage: 1 })
|
|
241
|
+
return { ok: !error, latencyMs: Date.now() - start }
|
|
242
|
+
})(),
|
|
243
|
+
|
|
244
|
+
// Storage health: list buckets
|
|
245
|
+
(async () => {
|
|
246
|
+
const start = Date.now()
|
|
247
|
+
const { error } = await supabase.storage.listBuckets()
|
|
248
|
+
return { ok: !error, latencyMs: Date.now() - start }
|
|
249
|
+
})(),
|
|
250
|
+
])
|
|
251
|
+
|
|
252
|
+
const [db, auth, storage] = checks.map(r =>
|
|
253
|
+
r.status === 'fulfilled' ? r.value : { ok: false, latencyMs: -1 }
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
const allOk = db.ok && auth.ok && storage.ok
|
|
257
|
+
const anyOk = db.ok || auth.ok || storage.ok
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
status: allOk ? 'healthy' : anyOk ? 'degraded' : 'unhealthy',
|
|
261
|
+
services: { database: db, auth, storage },
|
|
262
|
+
timestamp: new Date().toISOString(),
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Periodic health check (call every 30s)
|
|
267
|
+
let lastHealth: HealthStatus | null = null
|
|
268
|
+
|
|
269
|
+
setInterval(async () => {
|
|
270
|
+
lastHealth = await checkHealth()
|
|
271
|
+
if (lastHealth.status !== 'healthy') {
|
|
272
|
+
console.warn('[HealthCheck]', JSON.stringify(lastHealth))
|
|
273
|
+
// Alert via webhook, PagerDuty, etc.
|
|
274
|
+
}
|
|
275
|
+
}, 30_000)
|
|
276
|
+
|
|
277
|
+
export function getLastHealth() { return lastHealth }
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
### Dual-Write for Critical Data
|
|
281
|
+
|
|
282
|
+
For operations where data loss is unacceptable (payments, audit logs), write to both Supabase and a backup store. Reconcile later if they diverge.
|
|
283
|
+
|
|
284
|
+
```typescript
|
|
285
|
+
// lib/dual-write.ts
|
|
286
|
+
import { createClient } from '@supabase/supabase-js'
|
|
287
|
+
import type { Database } from './database.types'
|
|
288
|
+
import Redis from 'ioredis'
|
|
289
|
+
|
|
290
|
+
const supabase = createClient<Database>(
|
|
291
|
+
process.env.SUPABASE_URL!,
|
|
292
|
+
process.env.SUPABASE_SERVICE_ROLE_KEY!,
|
|
293
|
+
{ auth: { autoRefreshToken: false, persistSession: false } }
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
const redis = new Redis(process.env.REDIS_URL!)
|
|
297
|
+
|
|
298
|
+
interface DualWriteResult<T> {
|
|
299
|
+
primary: { data: T | null; error: any }
|
|
300
|
+
backup: { ok: boolean; error?: string }
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export async function dualWrite<T>(
|
|
304
|
+
table: string,
|
|
305
|
+
record: Record<string, any>,
|
|
306
|
+
selectColumns: string
|
|
307
|
+
): Promise<DualWriteResult<T>> {
|
|
308
|
+
// Write to both stores concurrently
|
|
309
|
+
const [primary, backup] = await Promise.allSettled([
|
|
310
|
+
// Primary: Supabase
|
|
311
|
+
supabase
|
|
312
|
+
.from(table)
|
|
313
|
+
.insert(record)
|
|
314
|
+
.select(selectColumns)
|
|
315
|
+
.single(),
|
|
316
|
+
|
|
317
|
+
// Backup: Redis with 30-day TTL
|
|
318
|
+
redis.set(
|
|
319
|
+
`backup:${table}:${record.id ?? crypto.randomUUID()}`,
|
|
320
|
+
JSON.stringify({ ...record, _written_at: new Date().toISOString() }),
|
|
321
|
+
'EX', 30 * 24 * 60 * 60
|
|
322
|
+
),
|
|
323
|
+
])
|
|
324
|
+
|
|
325
|
+
const primaryResult = primary.status === 'fulfilled'
|
|
326
|
+
? primary.value
|
|
327
|
+
: { data: null, error: primary.reason }
|
|
328
|
+
|
|
329
|
+
const backupResult = backup.status === 'fulfilled'
|
|
330
|
+
? { ok: true }
|
|
331
|
+
: { ok: false, error: String(backup.reason) }
|
|
332
|
+
|
|
333
|
+
// Log if writes diverge
|
|
334
|
+
if (primaryResult.error && backupResult.ok) {
|
|
335
|
+
console.error(`[DualWrite] Primary failed but backup succeeded for ${table}`)
|
|
336
|
+
} else if (!primaryResult.error && !backupResult.ok) {
|
|
337
|
+
console.warn(`[DualWrite] Primary succeeded but backup failed for ${table}`)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return { primary: primaryResult, backup: backupResult }
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Usage: payment processing
|
|
344
|
+
async function recordPayment(payment: {
|
|
345
|
+
order_id: string
|
|
346
|
+
amount: number
|
|
347
|
+
currency: string
|
|
348
|
+
stripe_payment_id: string
|
|
349
|
+
}) {
|
|
350
|
+
const result = await dualWrite<Database['public']['Tables']['payments']['Row']>(
|
|
351
|
+
'payments',
|
|
352
|
+
{
|
|
353
|
+
...payment,
|
|
354
|
+
id: crypto.randomUUID(),
|
|
355
|
+
status: 'completed',
|
|
356
|
+
created_at: new Date().toISOString(),
|
|
357
|
+
},
|
|
358
|
+
'id, order_id, amount, status, created_at'
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
if (result.primary.error) {
|
|
362
|
+
// Primary write failed — payment is in Redis backup
|
|
363
|
+
// Trigger reconciliation job
|
|
364
|
+
console.error('Payment write failed, queued for reconciliation:', result.primary.error)
|
|
365
|
+
throw new Error(`Payment recording failed: ${result.primary.error.message}`)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return result.primary.data
|
|
369
|
+
}
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
### Reconciliation Job for Dual-Write
|
|
373
|
+
|
|
374
|
+
```typescript
|
|
375
|
+
// jobs/reconcile-payments.ts
|
|
376
|
+
import { createClient } from '@supabase/supabase-js'
|
|
377
|
+
import Redis from 'ioredis'
|
|
378
|
+
|
|
379
|
+
const supabase = createClient(
|
|
380
|
+
process.env.SUPABASE_URL!,
|
|
381
|
+
process.env.SUPABASE_SERVICE_ROLE_KEY!,
|
|
382
|
+
{ auth: { autoRefreshToken: false, persistSession: false } }
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
const redis = new Redis(process.env.REDIS_URL!)
|
|
386
|
+
|
|
387
|
+
export async function reconcilePayments() {
|
|
388
|
+
// Find all backup records that might not be in Supabase
|
|
389
|
+
const keys = await redis.keys('backup:payments:*')
|
|
390
|
+
|
|
391
|
+
for (const key of keys) {
|
|
392
|
+
const raw = await redis.get(key)
|
|
393
|
+
if (!raw) continue
|
|
394
|
+
|
|
395
|
+
const record = JSON.parse(raw)
|
|
396
|
+
|
|
397
|
+
// Check if it exists in Supabase
|
|
398
|
+
const { data } = await supabase
|
|
399
|
+
.from('payments')
|
|
400
|
+
.select('id')
|
|
401
|
+
.eq('id', record.id)
|
|
402
|
+
.maybeSingle()
|
|
403
|
+
|
|
404
|
+
if (!data) {
|
|
405
|
+
// Missing from Supabase — replay the write
|
|
406
|
+
const { error } = await supabase
|
|
407
|
+
.from('payments')
|
|
408
|
+
.insert(record)
|
|
409
|
+
.select()
|
|
410
|
+
|
|
411
|
+
if (!error) {
|
|
412
|
+
await redis.del(key)
|
|
413
|
+
console.log(`[Reconcile] Replayed payment ${record.id}`)
|
|
414
|
+
} else {
|
|
415
|
+
console.error(`[Reconcile] Failed to replay ${record.id}:`, error.message)
|
|
416
|
+
}
|
|
417
|
+
} else {
|
|
418
|
+
// Already in Supabase — clean up backup
|
|
419
|
+
await redis.del(key)
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
## Output
|
|
426
|
+
|
|
427
|
+
- Circuit breaker protecting database, auth, and storage calls independently
|
|
428
|
+
- Retry with exponential backoff and jitter for transient Supabase errors
|
|
429
|
+
- Offline queue buffering writes during network outages with auto-flush on reconnect
|
|
430
|
+
- Graceful degradation serving cached or fallback data when Supabase is unavailable
|
|
431
|
+
- Health check endpoint monitoring all three Supabase services
|
|
432
|
+
- Dual-write pattern ensuring critical data survives Supabase outages
|
|
433
|
+
- Reconciliation job detecting and replaying missing records
|
|
434
|
+
|
|
435
|
+
## Error Handling
|
|
436
|
+
|
|
437
|
+
| Issue | Cause | Solution |
|
|
438
|
+
|-------|-------|----------|
|
|
439
|
+
| Circuit stays OPEN indefinitely | Supabase extended outage | Monitor `status.supabase.com`; circuit auto-recovers via HALF_OPEN |
|
|
440
|
+
| Offline queue grows unbounded | User offline for hours | Cap queue at 1000 items; show UI warning at 100 |
|
|
441
|
+
| Stale cache served too long | Cache TTL too generous | Reduce `cacheTtlMs`; show "last updated" timestamp |
|
|
442
|
+
| Dual-write divergence | Network partition | Run reconciliation job every 5 min; alert on divergence count |
|
|
443
|
+
| Health check false positive | Transient network blip | Require 3 consecutive failures before marking unhealthy |
|
|
444
|
+
| Retry storm | All clients retry simultaneously | Jitter prevents thundering herd; circuit breaker stops retries when open |
|
|
445
|
+
|
|
446
|
+
## Examples
|
|
447
|
+
|
|
448
|
+
### Quick Health Check from CLI
|
|
449
|
+
|
|
450
|
+
```bash
|
|
451
|
+
curl -s https://your-app.com/api/health | jq '.services'
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
### Check Circuit Breaker Status
|
|
455
|
+
|
|
456
|
+
```typescript
|
|
457
|
+
import { dbCircuit, authCircuit, storageCircuit } from '../lib/circuit-breaker'
|
|
458
|
+
|
|
459
|
+
function getCircuitStatus() {
|
|
460
|
+
return {
|
|
461
|
+
database: dbCircuit.currentState,
|
|
462
|
+
auth: authCircuit.currentState,
|
|
463
|
+
storage: storageCircuit.currentState,
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
### Test Offline Queue Pending Count
|
|
469
|
+
|
|
470
|
+
```typescript
|
|
471
|
+
import { offlineQueue } from '../lib/offline-queue'
|
|
472
|
+
|
|
473
|
+
const pending = await offlineQueue.pendingCount
|
|
474
|
+
if (pending > 0) {
|
|
475
|
+
showBanner(`${pending} changes waiting to sync`)
|
|
476
|
+
}
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
## Resources
|
|
480
|
+
|
|
481
|
+
- [Circuit Breaker Pattern (Martin Fowler)](https://martinfowler.com/bliki/CircuitBreaker.html)
|
|
482
|
+
- [Exponential Backoff (AWS)](https://docs.aws.amazon.com/general/latest/gr/api-retries.html)
|
|
483
|
+
- [Supabase Status Page](https://status.supabase.com)
|
|
484
|
+
- [IndexedDB API](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API)
|
|
485
|
+
- [Supabase Realtime (connection recovery)](https://supabase.com/docs/guides/realtime)
|
|
486
|
+
|
|
487
|
+
## Next Steps
|
|
488
|
+
|
|
489
|
+
For organizational governance and policy guardrails, see `supabase-policy-guardrails`.
|