@edgedev/firebase 2.2.80 → 2.2.81

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edgedev/firebase",
3
- "version": "2.2.80",
3
+ "version": "2.2.81",
4
4
  "description": "Vue 3 / Nuxt 3 Plugin or Nuxt 3 plugin for firebase authentication and firestore.",
5
5
  "main": "index.ts",
6
6
  "scripts": {
package/src/functions.js CHANGED
@@ -1,4 +1,6 @@
1
1
  // START @edge/firebase functions
2
+ const { kvMirrorRetryWorker } = require('./kv/kvRetryWorker')
3
+ exports.kvMirrorRetryWorker = kvMirrorRetryWorker
2
4
  exports.edgeFirebase = require('./edgeFirebase')
3
5
  exports.cms = require('./cms')
4
- // END @edge/firebase functions
6
+ // END @edge/firebase functions
package/src/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  require('dotenv').config({ path: process.env.NODE_ENV === 'production' ? '.env.prod' : '.env.dev' })
2
2
 
3
3
  // START @edge/firebase functions
4
+ const { kvMirrorRetryWorker } = require('./kv/kvRetryWorker')
5
+ exports.kvMirrorRetryWorker = kvMirrorRetryWorker
4
6
  exports.edgeFirebase = require('./edgeFirebase')
5
7
  exports.cms = require('./cms')
6
8
  // END @edge/firebase functions
@@ -11,6 +11,56 @@ if (!accountId || !namespaceId || !apiKey) {
11
11
  }
12
12
 
13
13
  const base = `https://api.cloudflare.com/client/v4/accounts/${accountId}/storage/kv/namespaces/${namespaceId}`
14
+ const MAX_RETRIES = Number(process.env.KV_HTTP_MAX_RETRIES || 5)
15
+ const BASE_DELAY_MS = Number(process.env.KV_HTTP_BASE_DELAY_MS || 250)
16
+ const MAX_DELAY_MS = Number(process.env.KV_HTTP_MAX_DELAY_MS || 5000)
17
+
18
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
19
+
20
+ function parseRetryAfterMs(retryAfterHeader) {
21
+ if (!retryAfterHeader)
22
+ return 0
23
+ const raw = Array.isArray(retryAfterHeader) ? retryAfterHeader[0] : retryAfterHeader
24
+ const asNumber = Number(raw)
25
+ if (Number.isFinite(asNumber) && asNumber >= 0)
26
+ return asNumber * 1000
27
+ const asDate = Date.parse(String(raw))
28
+ if (!Number.isNaN(asDate))
29
+ return Math.max(0, asDate - Date.now())
30
+ return 0
31
+ }
32
+
33
+ function shouldRetryError(err) {
34
+ const status = Number(err?.response?.status || 0)
35
+ if (status === 429 || status === 408)
36
+ return true
37
+ if (status >= 500 && status <= 599)
38
+ return true
39
+ if (!status && (err?.code || err?.message))
40
+ return true
41
+ return false
42
+ }
43
+
44
+ async function requestWithRetry(run, label = 'kv-request') {
45
+ let attempt = 0
46
+ for (;;) {
47
+ try {
48
+ return await run()
49
+ }
50
+ catch (err) {
51
+ if (!shouldRetryError(err) || attempt >= MAX_RETRIES)
52
+ throw err
53
+ const retryAfterMs = parseRetryAfterMs(err?.response?.headers?.['retry-after'])
54
+ const expo = Math.min(MAX_DELAY_MS, BASE_DELAY_MS * (2 ** attempt))
55
+ const jitter = Math.floor(Math.random() * 200)
56
+ const delayMs = Math.max(retryAfterMs, expo + jitter)
57
+ const status = err?.response?.status || 'network'
58
+ console.warn(`[kvClient] retrying ${label} (attempt ${attempt + 1}/${MAX_RETRIES}, status ${status}) in ${delayMs}ms`)
59
+ await sleep(delayMs)
60
+ attempt += 1
61
+ }
62
+ }
63
+ }
14
64
 
15
65
  function parseJsonIfNeeded(body) {
16
66
  if (body === null || body === undefined) {
@@ -61,7 +111,7 @@ async function put(key, value, opts = undefined) {
61
111
  form.append('metadata', JSON.stringify(opts.metadata))
62
112
  const formHeaders = form.getHeaders()
63
113
  Object.assign(headers, formHeaders)
64
- const res = await axios.put(url, form, { headers })
114
+ const res = await requestWithRetry(() => axios.put(url, form, { headers }), `put:${key}`)
65
115
  if (res.status !== 200) {
66
116
  throw new Error(`KV put failed: ${res.status} ${res.statusText}`)
67
117
  }
@@ -77,7 +127,7 @@ async function put(key, value, opts = undefined) {
77
127
  headers['Content-Type'] = 'text/plain'
78
128
  }
79
129
 
80
- const res = await axios.put(url, data, { headers })
130
+ const res = await requestWithRetry(() => axios.put(url, data, { headers }), `put:${key}`)
81
131
  if (res.status !== 200) {
82
132
  throw new Error(`KV put failed: ${res.status} ${res.statusText}`)
83
133
  }
@@ -99,11 +149,11 @@ async function putIndexMeta(key, metadata, opts = undefined) {
99
149
  async function get(key, type = 'text') {
100
150
  const url = `${base}/values/${encodeURIComponent(key)}`
101
151
  try {
102
- const res = await axios.get(url, {
152
+ const res = await requestWithRetry(() => axios.get(url, {
103
153
  headers: { Authorization: `Bearer ${apiKey}` },
104
154
  responseType: 'text',
105
155
  transformResponse: [x => x],
106
- })
156
+ }), `get:${key}`)
107
157
  if (type === 'json') {
108
158
  return parseJsonIfNeeded(res.data)
109
159
  }
@@ -119,9 +169,17 @@ async function get(key, type = 'text') {
119
169
 
120
170
  async function del(key) {
121
171
  const url = `${base}/values/${encodeURIComponent(key)}`
122
- await Promise.allSettled([
123
- axios.delete(url, { headers: { Authorization: `Bearer ${apiKey}` } }),
124
- ])
172
+ try {
173
+ await requestWithRetry(
174
+ () => axios.delete(url, { headers: { Authorization: `Bearer ${apiKey}` } }),
175
+ `del:${key}`,
176
+ )
177
+ }
178
+ catch (err) {
179
+ if (err?.response?.status === 404)
180
+ return
181
+ throw err
182
+ }
125
183
  }
126
184
 
127
185
  /**
@@ -139,7 +197,10 @@ async function listKeys({ prefix = '', limit = 1000, cursor = '' } = {}) {
139
197
  if (cursor) {
140
198
  u.searchParams.set('cursor', cursor)
141
199
  }
142
- const res = await axios.get(u.toString(), { headers: { Authorization: `Bearer ${apiKey}` } })
200
+ const res = await requestWithRetry(
201
+ () => axios.get(u.toString(), { headers: { Authorization: `Bearer ${apiKey}` } }),
202
+ `list:${prefix || 'all'}`,
203
+ )
143
204
  return res.data
144
205
  }
145
206
 
@@ -5,13 +5,115 @@
5
5
  // - Index-key metadata always includes { canonical: <canonicalKey> }.
6
6
  // - Keeps a small manifest per canonical key to clean up all index keys on delete.
7
7
 
8
- const { onDocumentWritten } = require('../config.js')
8
+ const { onDocumentWritten, db, Firestore, logger } = require('../config.js')
9
9
  const kv = require('./kvClient')
10
10
 
11
11
  function json(x) {
12
12
  return JSON.stringify(x)
13
13
  }
14
14
 
15
+ const KV_RETRY_TOPIC = process.env.KV_RETRY_TOPIC || 'kv-mirror-retry'
16
+ const INDEX_WRITE_CONCURRENCY = Number(process.env.KV_MIRROR_INDEX_CONCURRENCY || 20)
17
+
18
+ function toSortedUniqueStrings(values = []) {
19
+ return [...new Set((Array.isArray(values) ? values : [])
20
+ .filter(Boolean)
21
+ .map(String))]
22
+ .sort()
23
+ }
24
+
25
+ function stableStringify(value) {
26
+ if (Array.isArray(value)) {
27
+ return `[${value.map(stableStringify).join(',')}]`
28
+ }
29
+ if (value && typeof value === 'object') {
30
+ const keys = Object.keys(value).sort()
31
+ return `{${keys.map(k => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`
32
+ }
33
+ return JSON.stringify(value)
34
+ }
35
+
36
+ function areSameStrings(a = [], b = []) {
37
+ if (a.length !== b.length)
38
+ return false
39
+ for (let i = 0; i < a.length; i += 1) {
40
+ if (a[i] !== b[i])
41
+ return false
42
+ }
43
+ return true
44
+ }
45
+
46
+ function normalizeConcurrency(value) {
47
+ const n = Number(value)
48
+ if (!Number.isFinite(n) || n < 1)
49
+ return 1
50
+ return Math.floor(n)
51
+ }
52
+
53
+ async function runWithConcurrency(items, limit, worker) {
54
+ const values = Array.isArray(items) ? items : []
55
+ if (!values.length)
56
+ return
57
+ const max = normalizeConcurrency(limit)
58
+ let cursor = 0
59
+ const workers = Array.from({ length: Math.min(max, values.length) }, async () => {
60
+ for (;;) {
61
+ const idx = cursor
62
+ cursor += 1
63
+ if (idx >= values.length)
64
+ return
65
+ await worker(values[idx], idx)
66
+ }
67
+ })
68
+ await Promise.all(workers)
69
+ }
70
+
71
+ async function enqueueKvRetry(payload, minuteDelay = 1) {
72
+ const safePayload = payload && typeof payload === 'object' ? payload : {}
73
+ await db.collection('topic-queue').add({
74
+ topic: KV_RETRY_TOPIC,
75
+ payload: {
76
+ ...safePayload,
77
+ attempt: Number(safePayload.attempt || 0),
78
+ },
79
+ minuteDelay: Number(minuteDelay || 0),
80
+ retry: 0,
81
+ timestamp: Firestore.FieldValue.serverTimestamp(),
82
+ })
83
+ }
84
+
85
+ async function safeKvOperation({
86
+ run,
87
+ payload,
88
+ label,
89
+ }) {
90
+ try {
91
+ await run()
92
+ return true
93
+ }
94
+ catch (err) {
95
+ const message = String(err?.message || err || 'KV operation failed')
96
+ logger.warn('KV operation failed; queued for retry', {
97
+ label,
98
+ error: message.slice(0, 500),
99
+ key: payload?.key,
100
+ op: payload?.op,
101
+ })
102
+ try {
103
+ await enqueueKvRetry(payload)
104
+ }
105
+ catch (queueErr) {
106
+ logger.error('Failed to enqueue KV retry', {
107
+ label,
108
+ key: payload?.key,
109
+ op: payload?.op,
110
+ error: String(queueErr?.message || queueErr || 'enqueue failed').slice(0, 500),
111
+ })
112
+ }
113
+ return false
114
+ }
115
+ }
116
+
15
117
  function slugIndexValue(value, maxLength = 80) {
16
118
  return String(value ?? '')
17
119
  .trim()
@@ -53,6 +155,10 @@ function createKvMirrorHandler({
53
155
  const data = after?.exists ? after.data() : null
54
156
 
55
157
  const canonicalKey = makeCanonicalKey(params, data)
158
+ if (!canonicalKey) {
159
+ logger.warn('KV mirror skipped due to missing canonical key', { document })
160
+ return
161
+ }
56
162
  const indexingEnabled = typeof makeIndexKeys === 'function'
57
163
  const manifestKey = indexingEnabled ? `idx:manifest:${canonicalKey}` : null
58
164
 
@@ -65,16 +171,25 @@ function createKvMirrorHandler({
65
171
  catch (_) {
66
172
  prev = null
67
173
  }
68
- const keys = Array.isArray(prev?.indexKeys) ? prev.indexKeys : []
69
- const deletions = [
70
- ...keys.map(k => kv.del(k)),
71
- kv.del(canonicalKey),
72
- kv.del(manifestKey),
73
- ]
74
- await Promise.allSettled(deletions)
174
+ const keys = toSortedUniqueStrings([
175
+ ...(Array.isArray(prev?.indexKeys) ? prev.indexKeys : []),
176
+ canonicalKey,
177
+ manifestKey,
178
+ ])
179
+ await runWithConcurrency(keys, INDEX_WRITE_CONCURRENCY, async (key) => {
180
+ await safeKvOperation({
181
+ run: () => kv.del(key),
182
+ payload: { op: 'del', key, source: 'kvMirror' },
183
+ label: `del:${key}`,
184
+ })
185
+ })
75
186
  }
76
187
  else {
77
- await kv.del(canonicalKey)
188
+ await safeKvOperation({
189
+ run: () => kv.del(canonicalKey),
190
+ payload: { op: 'del', key: canonicalKey, source: 'kvMirror' },
191
+ label: `del:${canonicalKey}`,
192
+ })
78
193
  }
79
194
  return
80
195
  }
@@ -85,15 +200,25 @@ function createKvMirrorHandler({
85
200
  ? { ...customMetaCandidate, canonical: canonicalKey }
86
201
  : baseMeta
87
202
 
88
- await kv.put(canonicalKey, serialize(data), { metadata: metaValue })
203
+ const serializedData = serialize(data)
204
+ await safeKvOperation({
205
+ run: () => kv.put(canonicalKey, serializedData, { metadata: metaValue }),
206
+ payload: {
207
+ op: 'put',
208
+ key: canonicalKey,
209
+ value: serializedData,
210
+ opts: { metadata: metaValue },
211
+ source: 'kvMirror',
212
+ },
213
+ label: `put:${canonicalKey}`,
214
+ })
89
215
 
90
216
  if (!indexingEnabled) {
91
217
  return
92
218
  }
93
219
 
94
- const nextIndexKeys = (await Promise.resolve(makeIndexKeys(params, data)) || [])
95
- .filter(Boolean)
96
- .map(String)
220
+ const resolvedIndexKeys = await Promise.resolve(makeIndexKeys(params, data))
221
+ const nextIndexKeys = toSortedUniqueStrings(resolvedIndexKeys || [])
97
222
 
98
223
  let prev = null
99
224
  try {
@@ -103,16 +228,49 @@ function createKvMirrorHandler({
103
228
  prev = null
104
229
  }
105
230
 
106
- const oldIndexKeys = Array.isArray(prev?.indexKeys) ? prev.indexKeys : []
107
- const { toRemove } = setDiff(oldIndexKeys, nextIndexKeys)
231
+ const oldIndexKeys = toSortedUniqueStrings(Array.isArray(prev?.indexKeys) ? prev.indexKeys : [])
232
+ const previousMetaHash = typeof prev?.metadataHash === 'string' ? prev.metadataHash : ''
233
+ const currentMetaHash = stableStringify(metaValue)
234
+ const { toRemove, toAdd } = setDiff(oldIndexKeys, nextIndexKeys)
235
+ const shouldRewriteAllIndexKeys = previousMetaHash !== currentMetaHash
236
+ const keysToUpsert = shouldRewriteAllIndexKeys ? nextIndexKeys : toAdd
108
237
 
109
- const upserts = nextIndexKeys.map(k => kv.putIndexMeta(k, metaValue))
110
- await Promise.allSettled(upserts)
238
+ await runWithConcurrency(keysToUpsert, INDEX_WRITE_CONCURRENCY, async (key) => {
239
+ await safeKvOperation({
240
+ run: () => kv.putIndexMeta(key, metaValue),
241
+ payload: {
242
+ op: 'putIndexMeta',
243
+ key,
244
+ metadata: metaValue,
245
+ source: 'kvMirror',
246
+ },
247
+ label: `putIndexMeta:${key}`,
248
+ })
249
+ })
111
250
 
112
- const removals = toRemove.map(k => kv.del(k))
113
- await Promise.allSettled(removals)
251
+ await runWithConcurrency(toRemove, INDEX_WRITE_CONCURRENCY, async (key) => {
252
+ await safeKvOperation({
253
+ run: () => kv.del(key),
254
+ payload: { op: 'del', key, source: 'kvMirror' },
255
+ label: `del:${key}`,
256
+ })
257
+ })
114
258
 
115
- await kv.put(manifestKey, { indexKeys: nextIndexKeys })
259
+ const manifestUnchanged = areSameStrings(oldIndexKeys, nextIndexKeys)
260
+ && previousMetaHash === currentMetaHash
261
+ if (!manifestUnchanged) {
262
+ const manifestValue = { indexKeys: nextIndexKeys, metadataHash: currentMetaHash }
263
+ await safeKvOperation({
264
+ run: () => kv.put(manifestKey, manifestValue),
265
+ payload: {
266
+ op: 'put',
267
+ key: manifestKey,
268
+ value: manifestValue,
269
+ source: 'kvMirror',
270
+ },
271
+ label: `put:${manifestKey}`,
272
+ })
273
+ }
116
274
  })
117
275
  }
118
276
 
@@ -121,6 +279,7 @@ function createKvMirrorHandlerFromFields({
121
279
  uniqueKey,
122
280
  indexKeys = [],
123
281
  metadataKeys = [],
282
+ metaKeyTruncate = {},
124
283
  serialize = json,
125
284
  }) {
126
285
  if (!uniqueKey || typeof uniqueKey !== 'string') {
@@ -193,8 +352,18 @@ function createKvMirrorHandlerFromFields({
193
352
  const makeMetadata = (data) => {
194
353
  const meta = {}
195
354
  const keys = Array.isArray(metadataKeys) ? metadataKeys : []
355
+ const truncateMap = metaKeyTruncate && typeof metaKeyTruncate === 'object'
356
+ ? metaKeyTruncate
357
+ : {}
196
358
  for (const key of keys) {
197
- meta[key] = data?.[key] ?? ''
359
+ const raw = data?.[key] ?? ''
360
+ const truncateLength = Number(truncateMap?.[key])
361
+ if (Number.isFinite(truncateLength) && truncateLength >= 0 && typeof raw === 'string') {
362
+ meta[key] = raw.slice(0, Math.floor(truncateLength))
363
+ }
364
+ else {
365
+ meta[key] = raw
366
+ }
198
367
  }
199
368
  return meta
200
369
  }
@@ -0,0 +1,138 @@
1
+ const { onMessagePublished, db, Firestore, logger } = require('../config.js')
2
+ const kv = require('./kvClient')
3
+
4
+ const KV_RETRY_TOPIC = process.env.KV_RETRY_TOPIC || 'kv-mirror-retry'
5
+ const KV_RETRY_MAX_ATTEMPTS = Number(process.env.KV_RETRY_MAX_ATTEMPTS || 8)
6
+ const KV_RETRY_BASE_MIN_DELAY = Number(process.env.KV_RETRY_BASE_MIN_DELAY || 1)
7
+ const KV_RETRY_MAX_MIN_DELAY = Number(process.env.KV_RETRY_MAX_MIN_DELAY || 60)
8
+
9
+ function toPositiveInt(value, fallback = 1) {
10
+ const n = Number(value)
11
+ if (!Number.isFinite(n) || n < 1)
12
+ return fallback
13
+ return Math.floor(n)
14
+ }
15
+
16
+ function parseTopicPayload(event) {
17
+ const message = event?.data?.message || {}
18
+ if (message.json && typeof message.json === 'object')
19
+ return message.json
20
+ if (message.data) {
21
+ try {
22
+ const text = Buffer.from(message.data, 'base64').toString('utf8')
23
+ const parsed = JSON.parse(text)
24
+ return parsed && typeof parsed === 'object' ? parsed : {}
25
+ }
26
+ catch (_) {
27
+ return {}
28
+ }
29
+ }
30
+ return {}
31
+ }
32
+
33
+ function computeRetryDelayMinutes(attempt) {
34
+ const safeAttempt = toPositiveInt(attempt, 1)
35
+ const base = toPositiveInt(KV_RETRY_BASE_MIN_DELAY, 1)
36
+ const max = toPositiveInt(KV_RETRY_MAX_MIN_DELAY, 60)
37
+ return Math.min(max, base * (2 ** Math.max(0, safeAttempt - 1)))
38
+ }
39
+
40
+ async function enqueueKvRetry(payload, minuteDelay = 0) {
41
+ await db.collection('topic-queue').add({
42
+ topic: KV_RETRY_TOPIC,
43
+ payload,
44
+ minuteDelay: Number(minuteDelay || 0),
45
+ retry: 0,
46
+ timestamp: Firestore.FieldValue.serverTimestamp(),
47
+ })
48
+ }
49
+
50
+ async function runKvOperation(payload) {
51
+ const op = String(payload?.op || '')
52
+ const key = String(payload?.key || '')
53
+ if (!op || !key)
54
+ throw new Error('Invalid KV retry payload: missing op/key')
55
+
56
+ if (op === 'put')
57
+ return kv.put(key, payload.value, payload.opts)
58
+ if (op === 'putIndexMeta')
59
+ return kv.putIndexMeta(key, payload.metadata, payload.opts)
60
+ if (op === 'del')
61
+ return kv.del(key)
62
+
63
+ throw new Error(`Unsupported KV retry operation: ${op}`)
64
+ }
65
+
66
+ const kvMirrorRetryWorker = onMessagePublished(
67
+ {
68
+ topic: KV_RETRY_TOPIC,
69
+ retry: false,
70
+ timeoutSeconds: 180,
71
+ memory: '512MiB',
72
+ concurrency: 20,
73
+ },
74
+ async (event) => {
75
+ const payload = parseTopicPayload(event)
76
+ const op = String(payload?.op || '')
77
+ const key = String(payload?.key || '')
78
+ const attempt = Number(payload?.attempt || 0)
79
+
80
+ if (!op || !key) {
81
+ logger.warn('KV retry worker received invalid payload', { payload })
82
+ return
83
+ }
84
+
85
+ try {
86
+ await runKvOperation(payload)
87
+ }
88
+ catch (err) {
89
+ const nextAttempt = attempt + 1
90
+ const maxAttempts = toPositiveInt(KV_RETRY_MAX_ATTEMPTS, 8)
91
+ const errorMessage = String(err?.message || err || 'KV retry failed')
92
+
93
+ if (nextAttempt > maxAttempts) {
94
+ logger.error('KV retry exhausted max attempts', { op, key, attempt: nextAttempt, error: errorMessage.slice(0, 500) })
95
+ try {
96
+ await db.collection('kv-retry-dead').add({
97
+ topic: KV_RETRY_TOPIC,
98
+ payload: {
99
+ ...payload,
100
+ attempt: nextAttempt,
101
+ },
102
+ error: errorMessage.slice(0, 1000),
103
+ timestamp: Firestore.FieldValue.serverTimestamp(),
104
+ })
105
+ }
106
+ catch (deadErr) {
107
+ logger.error('KV retry dead-letter write failed', {
108
+ op,
109
+ key,
110
+ attempt: nextAttempt,
111
+ error: String(deadErr?.message || deadErr || 'dead-letter write failed').slice(0, 500),
112
+ })
113
+ }
114
+ return
115
+ }
116
+
117
+ const minuteDelay = computeRetryDelayMinutes(nextAttempt)
118
+ try {
119
+ await enqueueKvRetry({
120
+ ...payload,
121
+ attempt: nextAttempt,
122
+ }, minuteDelay)
123
+ logger.warn('KV retry requeued', { op, key, attempt: nextAttempt, minuteDelay, error: errorMessage.slice(0, 500) })
124
+ }
125
+ catch (queueErr) {
126
+ logger.error('KV retry requeue write failed', {
127
+ op,
128
+ key,
129
+ attempt: nextAttempt,
130
+ minuteDelay,
131
+ error: String(queueErr?.message || queueErr || 'requeue write failed').slice(0, 500),
132
+ })
133
+ }
134
+ }
135
+ },
136
+ )
137
+
138
+ module.exports = { kvMirrorRetryWorker }