@edgedev/firebase 2.2.80 → 2.2.82

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.82",
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/cms.js CHANGED
@@ -1839,12 +1839,18 @@ const buildBlockAiPrompt = ({
1839
1839
  ].join('\n')
1840
1840
  }
1841
1841
 
1842
+ const assertCallableUser = (request) => {
1843
+ if (!request?.auth?.uid)
1844
+ throw new HttpsError('unauthenticated', 'Authentication required.')
1845
+ if (request?.data?.uid !== request.auth.uid)
1846
+ throw new HttpsError('permission-denied', 'UID mismatch.')
1847
+ }
1848
+
1842
1849
  exports.updateSeoFromAi = onCall({ timeoutSeconds: 180 }, async (request) => {
1850
+ assertCallableUser(request)
1843
1851
  const data = request.data || {}
1844
1852
  const auth = request.auth
1845
- const { orgId, siteId, pageId, uid } = data
1846
- if (!auth?.uid || auth.uid !== uid)
1847
- throw new HttpsError('permission-denied', 'Unauthorized')
1853
+ const { orgId, siteId, pageId } = data
1848
1854
  if (!orgId || !siteId || !pageId)
1849
1855
  throw new HttpsError('invalid-argument', 'Missing orgId, siteId, or pageId')
1850
1856
  const allowed = await permissionCheck(auth.uid, 'write', `organizations/${orgId}/sites/${siteId}/pages`)
@@ -1910,9 +1916,7 @@ exports.updateSeoFromAi = onCall({ timeoutSeconds: 180 }, async (request) => {
1910
1916
  })
1911
1917
 
1912
1918
  exports.getCloudflarePagesProject = onCall(async (request) => {
1913
- if (!request?.auth) {
1914
- throw new HttpsError('unauthenticated', 'Authentication required.')
1915
- }
1919
+ assertCallableUser(request)
1916
1920
 
1917
1921
  if (!CLOUDFLARE_PAGES_PROJECT) {
1918
1922
  logger.warn('CLOUDFLARE_PAGES_PROJECT is not set.')
@@ -1923,12 +1927,10 @@ exports.getCloudflarePagesProject = onCall(async (request) => {
1923
1927
  })
1924
1928
 
1925
1929
  exports.generateBlockFields = onCall({ timeoutSeconds: 180 }, async (request) => {
1930
+ assertCallableUser(request)
1926
1931
  const data = request.data || {}
1927
1932
  const auth = request.auth
1928
- const { orgId, uid, blockId, blockName, content, fields, currentValues, meta, instructions } = data
1929
-
1930
- if (!auth?.uid || auth.uid !== uid)
1931
- throw new HttpsError('permission-denied', 'Unauthorized')
1933
+ const { orgId, blockId, blockName, content, fields, currentValues, meta, instructions } = data
1932
1934
  if (!orgId || !blockId)
1933
1935
  throw new HttpsError('invalid-argument', 'Missing orgId or blockId')
1934
1936
  if (!Array.isArray(fields) || fields.length === 0)
@@ -14,6 +14,13 @@ function formatPhoneNumber(phone) {
14
14
  return `+1${numericPhone}`
15
15
  }
16
16
 
17
+ const assertCallableUser = (request) => {
18
+ if (!request?.auth?.uid)
19
+ throw new HttpsError('unauthenticated', 'Authentication required.')
20
+ if (request?.data?.uid !== request.auth.uid)
21
+ throw new HttpsError('permission-denied', 'UID mismatch.')
22
+ }
23
+
17
24
  exports.uploadDocumentDeleted = onDocumentDeleted(
18
25
  { document: 'organizations/{orgId}/files/{docId}', timeoutSeconds: 180 },
19
26
  async (event) => {
@@ -34,21 +41,19 @@ exports.uploadDocumentDeleted = onDocumentDeleted(
34
41
  )
35
42
 
36
43
  exports.addUpdateFileDoc = onCall(async (request) => {
44
+ assertCallableUser(request)
37
45
  const data = request.data
38
- const auth = request.auth
39
46
  let docId = data?.docId
40
- if (data.uid === auth.uid) {
41
- console.log(data)
42
- const orgId = data.orgId
43
- if (docId) {
44
- const docRef = db.collection(`organizations/${orgId}/files`).doc(docId)
45
- await docRef.set(data, { merge: true })
46
- }
47
- else {
48
- const docRef = db.collection(`organizations/${orgId}/files`).doc()
49
- await docRef.set(data)
50
- docId = docRef.id
51
- }
47
+ console.log(data)
48
+ const orgId = data.orgId
49
+ if (docId) {
50
+ const docRef = db.collection(`organizations/${orgId}/files`).doc(docId)
51
+ await docRef.set(data, { merge: true })
52
+ }
53
+ else {
54
+ const docRef = db.collection(`organizations/${orgId}/files`).doc()
55
+ await docRef.set(data)
56
+ docId = docRef.id
52
57
  }
53
58
  console.log(docId)
54
59
  return { docId }
@@ -426,91 +431,87 @@ exports.initFirestore = onCall(async (request) => {
426
431
  })
427
432
 
428
433
  exports.removeNonRegisteredUser = onCall(async (request) => {
434
+ assertCallableUser(request)
429
435
  const data = request.data
430
- const auth = request.auth
431
- if (data.uid === auth.uid) {
432
- const stagedUser = await db.collection('staged-users').doc(data.docId).get()
433
- if (stagedUser.exists) {
434
- const stagedUserData = stagedUser.data()
435
-
436
- const rolesExist = stagedUserData.roles && Object.keys(stagedUserData.roles).length !== 0
437
- const specialPermissionsExist = stagedUserData.specialPermissions && Object.keys(stagedUserData.specialPermissions).length !== 0
438
- const userIdExistsAndNotBlank = stagedUserData.userId && stagedUserData.userId !== ''
439
-
440
- if (!rolesExist && !specialPermissionsExist && !userIdExistsAndNotBlank) {
441
- await db.collection('staged-users').doc(data.docId).delete()
442
- return { success: true, message: '' }
436
+ const stagedUser = await db.collection('staged-users').doc(data.docId).get()
437
+ if (stagedUser.exists) {
438
+ const stagedUserData = stagedUser.data()
439
+
440
+ const rolesExist = stagedUserData.roles && Object.keys(stagedUserData.roles).length !== 0
441
+ const specialPermissionsExist = stagedUserData.specialPermissions && Object.keys(stagedUserData.specialPermissions).length !== 0
442
+ const userIdExistsAndNotBlank = stagedUserData.userId && stagedUserData.userId !== ''
443
+
444
+ if (!rolesExist && !specialPermissionsExist && !userIdExistsAndNotBlank) {
445
+ await db.collection('staged-users').doc(data.docId).delete()
446
+ return { success: true, message: '' }
447
+ }
448
+ else {
449
+ let message = ''
450
+ if (rolesExist && specialPermissionsExist) {
451
+ message = 'Cannot delete because the non-registered user still has roles and special permissions assigned.'
443
452
  }
444
- else {
445
- let message = ''
446
- if (rolesExist && specialPermissionsExist) {
447
- message = 'Cannot delete because the non-registered user still has roles and special permissions assigned.'
448
- }
449
- else if (rolesExist) {
450
- message = 'Cannot delete because the non-registered user still has roles assigned.'
451
- }
452
- else if (specialPermissionsExist) {
453
- message = 'Cannot delete because the non-registered user still has special permissions assigned.'
454
- }
455
- else if (userIdExistsAndNotBlank) {
456
- message = 'Cannot delete because the user is registered.'
457
- }
458
- return { success: false, message }
453
+ else if (rolesExist) {
454
+ message = 'Cannot delete because the non-registered user still has roles assigned.'
455
+ }
456
+ else if (specialPermissionsExist) {
457
+ message = 'Cannot delete because the non-registered user still has special permissions assigned.'
458
+ }
459
+ else if (userIdExistsAndNotBlank) {
460
+ message = 'Cannot delete because the user is registered.'
459
461
  }
462
+ return { success: false, message }
460
463
  }
461
464
  }
462
465
  return { success: false, message: 'Non-registered user not found.' }
463
466
  })
464
467
 
465
468
  exports.currentUserRegister = onCall(async (request) => {
469
+ assertCallableUser(request)
466
470
  const data = request.data
467
- const auth = request.auth
468
- if (data.uid === auth.uid) {
469
- const stagedUser = await db.collection('staged-users').doc(data.registrationCode).get()
470
- if (!stagedUser.exists) {
471
- return { success: false, message: 'Registration code not found.' }
471
+ const stagedUser = await db.collection('staged-users').doc(data.registrationCode).get()
472
+ if (!stagedUser.exists) {
473
+ return { success: false, message: 'Registration code not found.' }
474
+ }
475
+ else {
476
+ const stagedUserData = await stagedUser.data()
477
+ let process = false
478
+ if (stagedUserData.isTemplate) {
479
+ process = true
472
480
  }
473
- else {
474
- const stagedUserData = await stagedUser.data()
475
- let process = false
476
- if (stagedUserData.isTemplate) {
477
- process = true
478
- }
479
- if (!stagedUserData.isTemplate && stagedUserData.userId === '') {
480
- process = true
481
- }
482
- if (!process) {
483
- return { success: false, message: 'Registration code not valid.' }
484
- }
485
- const newRoles = stagedUserData.roles || {}
486
- const currentUser = await db.collection('users').doc(data.uid).get()
487
- const currentUserData = await currentUser.data()
488
- const currentRoles = currentUserData.roles || {}
489
- const currentUserCollectionPaths = currentUserData.collectionPaths || []
490
- let newRole = {}
491
- if (stagedUserData.subCreate && Object.keys(stagedUserData.subCreate).length !== 0 && stagedUserData.isTemplate) {
492
- if (!data.dynamicDocumentFieldValue) {
493
- return { success: false, message: 'Dynamic document field value is required.' }
494
- }
495
- const rootPath = stagedUserData.subCreate.rootPath
496
- const newDoc = stagedUserData.subCreate.documentStructure
497
- newDoc[stagedUserData.subCreate.dynamicDocumentField] = data.dynamicDocumentFieldValue
498
- const addedDoc = await db.collection(rootPath).add(newDoc)
499
- await db.collection(rootPath).doc(addedDoc.id).update({ docId: addedDoc.id })
500
- newRole = { [`${rootPath}-${addedDoc.id}`]: { collectionPath: `${rootPath}-${addedDoc.id}`, role: stagedUserData.subCreate.role } }
481
+ if (!stagedUserData.isTemplate && stagedUserData.userId === '') {
482
+ process = true
483
+ }
484
+ if (!process) {
485
+ return { success: false, message: 'Registration code not valid.' }
486
+ }
487
+ const newRoles = stagedUserData.roles || {}
488
+ const currentUser = await db.collection('users').doc(data.uid).get()
489
+ const currentUserData = await currentUser.data()
490
+ const currentRoles = currentUserData.roles || {}
491
+ const currentUserCollectionPaths = currentUserData.collectionPaths || []
492
+ let newRole = {}
493
+ if (stagedUserData.subCreate && Object.keys(stagedUserData.subCreate).length !== 0 && stagedUserData.isTemplate) {
494
+ if (!data.dynamicDocumentFieldValue) {
495
+ return { success: false, message: 'Dynamic document field value is required.' }
501
496
  }
502
- const combinedRoles = { ...currentRoles, ...newRoles, ...newRole }
503
- Object.values(combinedRoles).forEach((role) => {
504
- if (!currentUserCollectionPaths.includes(role.collectionPath)) {
505
- currentUserCollectionPaths.push(role.collectionPath)
506
- }
507
- })
508
- await db.collection('staged-users').doc(currentUserData.stagedDocId).update({ roles: combinedRoles, collectionPaths: currentUserCollectionPaths })
509
- if (!stagedUserData.isTemplate) {
510
- await db.collection('staged-users').doc(data.registrationCode).delete()
497
+ const rootPath = stagedUserData.subCreate.rootPath
498
+ const newDoc = stagedUserData.subCreate.documentStructure
499
+ newDoc[stagedUserData.subCreate.dynamicDocumentField] = data.dynamicDocumentFieldValue
500
+ const addedDoc = await db.collection(rootPath).add(newDoc)
501
+ await db.collection(rootPath).doc(addedDoc.id).update({ docId: addedDoc.id })
502
+ newRole = { [`${rootPath}-${addedDoc.id}`]: { collectionPath: `${rootPath}-${addedDoc.id}`, role: stagedUserData.subCreate.role } }
503
+ }
504
+ const combinedRoles = { ...currentRoles, ...newRoles, ...newRole }
505
+ Object.values(combinedRoles).forEach((role) => {
506
+ if (!currentUserCollectionPaths.includes(role.collectionPath)) {
507
+ currentUserCollectionPaths.push(role.collectionPath)
511
508
  }
512
- return { success: true, message: '' }
509
+ })
510
+ await db.collection('staged-users').doc(currentUserData.stagedDocId).update({ roles: combinedRoles, collectionPaths: currentUserCollectionPaths })
511
+ if (!stagedUserData.isTemplate) {
512
+ await db.collection('staged-users').doc(data.registrationCode).delete()
513
513
  }
514
+ return { success: true, message: '' }
514
515
  }
515
516
  })
516
517
 
@@ -522,52 +523,51 @@ exports.checkOrgIdExists = onCall(async (request) => {
522
523
  })
523
524
 
524
525
  exports.deleteSelf = onCall(async (request) => {
525
- if (request.data.uid === request.auth.uid) {
526
- try {
527
- const userDoc = await db.collection('staged-users').doc(request.auth.uid).get()
528
- const userData = userDoc.data()
529
- const userCollectionPaths = userData.collectionPaths || []
530
-
531
- for (const path of userCollectionPaths) {
532
- const usersWithSamePath = await db.collection('staged-users').where('collectionPaths', 'array-contains', path).get()
533
-
534
- // If no other users have the same collection path, delete the path and all documents and collections under it
535
- if (usersWithSamePath.size <= 1) {
536
- const adjustedPath = path.replace(/-/g, '/')
537
- const docRef = db.doc(adjustedPath)
538
- const doc = await docRef.get()
539
-
540
- if (doc.exists) {
541
- // If the path is a document, delete it directly
542
- await docRef.delete()
543
- }
544
- else {
545
- // If the path is a collection, delete all documents under it
546
- const docsToDelete = await db.collection(adjustedPath).get()
547
- const batch = db.batch()
548
- docsToDelete.docs.forEach((doc) => {
549
- batch.delete(doc.ref)
550
- })
551
- await batch.commit()
552
- }
526
+ assertCallableUser(request)
527
+ try {
528
+ const userDoc = await db.collection('staged-users').doc(request.auth.uid).get()
529
+ const userData = userDoc.data()
530
+ const userCollectionPaths = userData.collectionPaths || []
531
+
532
+ for (const path of userCollectionPaths) {
533
+ const usersWithSamePath = await db.collection('staged-users').where('collectionPaths', 'array-contains', path).get()
534
+
535
+ // If no other users have the same collection path, delete the path and all documents and collections under it
536
+ if (usersWithSamePath.size <= 1) {
537
+ const adjustedPath = path.replace(/-/g, '/')
538
+ const docRef = db.doc(adjustedPath)
539
+ const doc = await docRef.get()
540
+
541
+ if (doc.exists) {
542
+ // If the path is a document, delete it directly
543
+ await docRef.delete()
544
+ }
545
+ else {
546
+ // If the path is a collection, delete all documents under it
547
+ const docsToDelete = await db.collection(adjustedPath).get()
548
+ const batch = db.batch()
549
+ docsToDelete.docs.forEach((doc) => {
550
+ batch.delete(doc.ref)
551
+ })
552
+ await batch.commit()
553
553
  }
554
554
  }
555
+ }
555
556
 
556
- // Delete from 'staged-users' collection
557
- await db.collection('staged-users').doc(request.data.uid).delete()
557
+ // Delete from 'staged-users' collection
558
+ await db.collection('staged-users').doc(request.data.uid).delete()
558
559
 
559
- // Delete from 'users' collection
560
- await db.collection('users').doc(request.data.uid).delete()
560
+ // Delete from 'users' collection
561
+ await db.collection('users').doc(request.data.uid).delete()
561
562
 
562
- // Delete the user from Firebase
563
- await admin.auth().deleteUser(request.data.uid)
563
+ // Delete the user from Firebase
564
+ await admin.auth().deleteUser(request.data.uid)
564
565
 
565
- return { success: true }
566
- }
567
- catch (error) {
568
- console.error('Error deleting user:', error)
569
- return { success: false, error }
570
- }
566
+ return { success: true }
567
+ }
568
+ catch (error) {
569
+ console.error('Error deleting user:', error)
570
+ return { success: false, error }
571
571
  }
572
572
  })
573
573
 
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 }