FDK session offline storage in boltic tables

This commit is contained in:
Ritul Jadhav 2026-03-31 11:20:53 +05:30
parent 1a86cea207
commit 2ccab54fb3
6 changed files with 420 additions and 11 deletions

View File

@ -99,6 +99,23 @@ const DEFAULT_EXPANDED_GROUPS = EVENT_GROUPS.reduce((acc, group) => {
acc[group.id] = group.defaultExpanded; acc[group.id] = group.defaultExpanded;
return acc; return acc;
}, {}); }, {});
const EVENT_TEMPLATE_STATUS_CONFIG = {
unselected: {
label: 'No template selected',
wrapper: 'border-gray-200 bg-gray-50 text-gray-500',
dot: 'bg-gray-400',
},
pending_whitelisting: {
label: 'Pending Whitelisting',
wrapper: 'border-amber-200 bg-amber-50 text-amber-700',
dot: 'bg-amber-500',
},
whitelisted: {
label: 'Published',
wrapper: 'border-green-200 bg-green-50 text-green-700',
dot: 'bg-green-500',
},
};
function getEventGroupId(event) { function getEventGroupId(event) {
const slug = String(event?.slug || ''); const slug = String(event?.slug || '');
@ -194,11 +211,17 @@ function syncDraftsWithVariants(existingDrafts, variantsBySlug) {
function buildTemplateUiState(templates = []) { function buildTemplateUiState(templates = []) {
const nextVariants = {}; const nextVariants = {};
const nextGenState = {}; const nextGenState = {};
const nextTemplateStatusBySlug = {};
templates.forEach((template) => { templates.forEach((template) => {
if (!template?.eventSlug) return; if (!template?.eventSlug) return;
if (template.selectedTemplate) { if (template.selectedTemplate) {
if (template.status === 'whitelisted') {
nextTemplateStatusBySlug[template.eventSlug] = 'whitelisted';
} else {
nextTemplateStatusBySlug[template.eventSlug] = 'pending_whitelisting';
}
nextGenState[template.eventSlug] = 'selected'; nextGenState[template.eventSlug] = 'selected';
return; return;
} }
@ -209,7 +232,7 @@ function buildTemplateUiState(templates = []) {
} }
}); });
return { nextVariants, nextGenState }; return { nextVariants, nextGenState, nextTemplateStatusBySlug };
} }
export default function Events() { export default function Events() {
@ -229,6 +252,7 @@ export default function Events() {
const [selectingVariantKey, setSelectingVariantKey] = useState(''); const [selectingVariantKey, setSelectingVariantKey] = useState('');
const [openVariableMenuKey, setOpenVariableMenuKey] = useState(''); const [openVariableMenuKey, setOpenVariableMenuKey] = useState('');
const [activeCaretVariantKey, setActiveCaretVariantKey] = useState(''); const [activeCaretVariantKey, setActiveCaretVariantKey] = useState('');
const [templateStatusBySlug, setTemplateStatusBySlug] = useState({});
const [error, setError] = useState(''); const [error, setError] = useState('');
const [readyToGenerate, setReadyToGenerate] = useState(false); const [readyToGenerate, setReadyToGenerate] = useState(false);
@ -259,12 +283,13 @@ export default function Events() {
]); ]);
const templates = templatesRes.data.templates || []; const templates = templatesRes.data.templates || [];
const { nextVariants, nextGenState } = buildTemplateUiState(templates); const { nextVariants, nextGenState, nextTemplateStatusBySlug } = buildTemplateUiState(templates);
setEvents(eventsRes.data.events || []); setEvents(eventsRes.data.events || []);
setReadyToGenerate(!!activeProfileRes.data?.activeProfile?.rawCurl); setReadyToGenerate(!!activeProfileRes.data?.activeProfile?.rawCurl);
setVariants(nextVariants); setVariants(nextVariants);
setGenState(nextGenState); setGenState(nextGenState);
setTemplateStatusBySlug(nextTemplateStatusBySlug);
setVariantDrafts((currentDrafts) => syncDraftsWithVariants(currentDrafts, nextVariants)); setVariantDrafts((currentDrafts) => syncDraftsWithVariants(currentDrafts, nextVariants));
} catch { } catch {
setError('Failed to load events'); setError('Failed to load events');
@ -323,6 +348,11 @@ export default function Events() {
})); }));
setOpenVariableMenuKey(''); setOpenVariableMenuKey('');
setActiveCaretVariantKey(''); setActiveCaretVariantKey('');
setTemplateStatusBySlug((currentStatuses) => {
const nextStatuses = { ...currentStatuses };
delete nextStatuses[slug];
return nextStatuses;
});
setGenState((state) => ({ ...state, [slug]: 'done' })); setGenState((state) => ({ ...state, [slug]: 'done' }));
} catch (err) { } catch (err) {
setError(err.response?.data?.error || 'Generation failed'); setError(err.response?.data?.error || 'Generation failed');
@ -390,8 +420,9 @@ export default function Events() {
setOpenVariableMenuKey(''); setOpenVariableMenuKey('');
setActiveCaretVariantKey(''); setActiveCaretVariantKey('');
setGenState((state) => ({ ...state, [slug]: 'selected' })); setGenState((state) => ({ ...state, [slug]: 'selected' }));
setTemplateStatusBySlug((currentStatuses) => ({ ...currentStatuses, [slug]: 'pending_whitelisting' }));
if (shouldAutoAdvance) { if (shouldAutoAdvance) {
navigate(`/${businessId}/templates`); navigate(`/${businessId}/templates?event=${encodeURIComponent(slug)}`);
} }
} catch (err) { } catch (err) {
setError(err.response?.data?.error || 'Failed to select template'); setError(err.response?.data?.error || 'Failed to select template');
@ -617,6 +648,9 @@ export default function Events() {
{group.events.map((event) => { {group.events.map((event) => {
const state = genState[event.slug] || 'idle'; const state = genState[event.slug] || 'idle';
const eventVariants = variants[event.slug] || []; const eventVariants = variants[event.slug] || [];
const templateStatus = templateStatusBySlug[event.slug] || 'unselected';
const statusConfig = EVENT_TEMPLATE_STATUS_CONFIG[templateStatus] || EVENT_TEMPLATE_STATUS_CONFIG.unselected;
const canViewTemplate = templateStatus !== 'unselected';
return ( return (
<div key={event.slug} className="rounded-xl bg-white border border-gray-200 shadow-sm overflow-hidden"> <div key={event.slug} className="rounded-xl bg-white border border-gray-200 shadow-sm overflow-hidden">
@ -641,10 +675,21 @@ export default function Events() {
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{state === 'selected' && ( <span
<span className="text-xs font-semibold px-2.5 py-1 rounded-md bg-green-50 border border-green-200 text-green-700"> title={statusConfig.label}
Template Selected aria-label={statusConfig.label}
className={`inline-flex h-9 w-9 items-center justify-center rounded-full border shadow-sm ${statusConfig.wrapper}`}
>
<span className={`h-2.5 w-2.5 rounded-full ${statusConfig.dot}`} />
</span> </span>
{canViewTemplate && (
<button
type="button"
onClick={() => navigate(`/${businessId}/templates?event=${encodeURIComponent(event.slug)}`)}
className="px-3.5 py-2 rounded-lg bg-white border border-gray-300 text-sm font-medium text-gray-700 hover:bg-gray-50 transition shadow-sm"
>
View in Templates
</button>
)} )}
<button <button
onClick={() => handleGenerate(event.slug)} onClick={() => handleGenerate(event.slug)}

View File

@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { useParams } from 'react-router-dom'; import { useParams, useSearchParams } from 'react-router-dom';
import apiClient from '../api/client'; import apiClient from '../api/client';
import WhitelistModal from '../components/WhitelistModal'; import WhitelistModal from '../components/WhitelistModal';
import TestSmsModal from '../components/TestSmsModal'; import TestSmsModal from '../components/TestSmsModal';
@ -12,6 +12,7 @@ const STATUS_CONFIG = {
export default function Templates() { export default function Templates() {
const { businessId } = useParams(); const { businessId } = useParams();
const [searchParams] = useSearchParams();
const [templates, setTemplates] = useState([]); const [templates, setTemplates] = useState([]);
const [profilesById, setProfilesById] = useState({}); const [profilesById, setProfilesById] = useState({});
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -19,6 +20,16 @@ export default function Templates() {
const [whitelistTarget, setWhitelistTarget] = useState(null); const [whitelistTarget, setWhitelistTarget] = useState(null);
const [testTarget, setTestTarget] = useState(null); const [testTarget, setTestTarget] = useState(null);
const [activeTab, setActiveTab] = useState('published'); // 'published' | 'pending' const [activeTab, setActiveTab] = useState('published'); // 'published' | 'pending'
const [highlightedEventSlug, setHighlightedEventSlug] = useState('');
const templateCardRefs = useRef({});
const highlightTimeoutRef = useRef(null);
const handledFocusSlugRef = useRef('');
const getTabForStatus = useCallback((status) => {
if (status === 'pending_whitelisting') return 'pending';
if (status === 'whitelisted') return 'published';
return null;
}, []);
const loadTemplates = useCallback(async () => { const loadTemplates = useCallback(async () => {
setLoading(true); setLoading(true);
@ -45,6 +56,45 @@ export default function Templates() {
loadTemplates(); loadTemplates();
}, [loadTemplates]); }, [loadTemplates]);
useEffect(() => {
return () => {
if (highlightTimeoutRef.current) {
window.clearTimeout(highlightTimeoutRef.current);
}
};
}, []);
useEffect(() => {
const targetEventSlug = searchParams.get('event');
if (!targetEventSlug || templates.length === 0) return;
if (handledFocusSlugRef.current === targetEventSlug) return;
const targetTemplate = templates.find(tmpl => tmpl.eventSlug === targetEventSlug);
if (!targetTemplate) return;
const targetTab = getTabForStatus(targetTemplate.status);
if (targetTab && activeTab !== targetTab) {
setActiveTab(targetTab);
return;
}
const targetCard = templateCardRefs.current[targetEventSlug];
if (!targetCard) return;
handledFocusSlugRef.current = targetEventSlug;
targetCard.scrollIntoView({ behavior: 'smooth', block: 'center' });
setHighlightedEventSlug(targetEventSlug);
if (highlightTimeoutRef.current) {
window.clearTimeout(highlightTimeoutRef.current);
}
highlightTimeoutRef.current = window.setTimeout(() => {
setHighlightedEventSlug(currentSlug => (currentSlug === targetEventSlug ? '' : currentSlug));
highlightTimeoutRef.current = null;
}, 2200);
}, [activeTab, getTabForStatus, searchParams, templates]);
async function handleWhitelistSuccess() { async function handleWhitelistSuccess() {
setWhitelistTarget(null); setWhitelistTarget(null);
await loadTemplates(); await loadTemplates();
@ -127,7 +177,21 @@ export default function Templates() {
: 'This template is not bound to a cURL profile. Re-select it from Events to continue.'; : 'This template is not bound to a cURL profile. Re-select it from Events to continue.';
return ( return (
<div key={tmpl.eventSlug} className="rounded-xl bg-white border border-gray-200 shadow-sm overflow-hidden"> <div
key={tmpl.eventSlug}
ref={(node) => {
if (node) {
templateCardRefs.current[tmpl.eventSlug] = node;
} else {
delete templateCardRefs.current[tmpl.eventSlug];
}
}}
className={`rounded-xl bg-white border shadow-sm overflow-hidden transition-all duration-300 ${
highlightedEventSlug === tmpl.eventSlug
? 'border-primary-blue ring-2 ring-indigo-200 animate-pulse'
: 'border-gray-200'
}`}
>
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/50 flex items-center justify-between"> <div className="px-6 py-4 border-b border-gray-100 bg-gray-50/50 flex items-center justify-between">
<div> <div>
<h3 className="text-base font-bold text-gray-900 capitalize tracking-tight"> <h3 className="text-base font-bold text-gray-900 capitalize tracking-tight">

View File

@ -2,6 +2,7 @@ require('dotenv').config();
const { setupFdk } = require('@gofynd/fdk-extension-javascript/express'); const { setupFdk } = require('@gofynd/fdk-extension-javascript/express');
const { MemoryStorage } = require('@gofynd/fdk-extension-javascript/express/storage'); const { MemoryStorage } = require('@gofynd/fdk-extension-javascript/express/storage');
const { createFdkStorage } = require('./postgresFdkStorage');
function normalizeEnvText(value) { function normalizeEnvText(value) {
return typeof value === 'string' ? value.trim() : ''; return typeof value === 'string' ? value.trim() : '';
@ -22,11 +23,21 @@ function createFdkExtension() {
const apiSecret = normalizeEnvText(process.env.EXTENSION_API_SECRET); const apiSecret = normalizeEnvText(process.env.EXTENSION_API_SECRET);
const baseUrl = normalizeEnvText(process.env.EXTENSION_BASE_URL || process.env.EXTENSION_URL); const baseUrl = normalizeEnvText(process.env.EXTENSION_BASE_URL || process.env.EXTENSION_URL);
const cluster = normalizeEnvText(process.env.EXTENSION_CLUSTER_URL) || 'https://api.fynd.com'; const cluster = normalizeEnvText(process.env.EXTENSION_CLUSTER_URL) || 'https://api.fynd.com';
const storageConnectionString = normalizeEnvText(process.env.FDK_STORAGE_CONNECTION_STRING);
if (!apiKey || !apiSecret || !baseUrl) { if (!apiKey || !apiSecret || !baseUrl) {
return null; return null;
} }
const storage = createFdkStorage({
prefixKey: 'sms_extension_',
connectionString: storageConnectionString,
}) || new MemoryStorage('sms_extension_');
if (!storageConnectionString) {
console.warn('[FDK] FDK_STORAGE_CONNECTION_STRING is not set; falling back to in-memory session storage.');
}
try { try {
return setupFdk({ return setupFdk({
api_key: apiKey, api_key: apiKey,
@ -41,7 +52,7 @@ function createFdkExtension() {
console.log(`[FDK] uninstall callback received for company ${companyId || 'unknown'}`); console.log(`[FDK] uninstall callback received for company ${companyId || 'unknown'}`);
}, },
}, },
storage: new MemoryStorage('sms_extension_'), storage,
access_mode: 'offline', access_mode: 'offline',
}); });
} catch (error) { } catch (error) {

139
server/package-lock.json generated
View File

@ -17,6 +17,7 @@
"express": "^4.18.2", "express": "^4.18.2",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"openai": "^4.28.0", "openai": "^4.28.0",
"pg": "^8.20.0",
"uuid": "^13.0.0" "uuid": "^13.0.0"
}, },
"devDependencies": { "devDependencies": {
@ -1668,6 +1669,96 @@
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/pg": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.12.0",
"pg-pool": "^3.13.0",
"pg-protocol": "^1.13.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.3.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz",
"integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.13.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz",
"integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz",
"integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "2.3.2", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
@ -1681,6 +1772,45 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/process-nextick-args": { "node_modules/process-nextick-args": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
@ -2007,6 +2137,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/stack-trace": { "node_modules/stack-trace": {
"version": "0.0.10", "version": "0.0.10",
"resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz",

View File

@ -17,6 +17,7 @@
"express": "^4.18.2", "express": "^4.18.2",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"openai": "^4.28.0", "openai": "^4.28.0",
"pg": "^8.20.0",
"uuid": "^13.0.0" "uuid": "^13.0.0"
}, },
"devDependencies": { "devDependencies": {

View File

@ -0,0 +1,149 @@
const { Pool } = require('pg');
const BaseStorage = require('@gofynd/fdk-extension-javascript/express/storage/base_storage');
const DEFAULT_TABLE_NAME = 'fdk session storage';
function normalizeEnvText(value) {
return typeof value === 'string' ? value.trim() : '';
}
function quoteIdentifier(identifier) {
return `"${String(identifier).replace(/"/g, '""')}"`;
}
function shouldUseSsl(connectionString) {
const normalized = normalizeEnvText(connectionString).toLowerCase();
if (!normalized) return false;
if (normalized.includes('sslmode=disable')) return false;
if (normalized.includes('sslmode=require')) return true;
return !normalized.includes('localhost') && !normalized.includes('127.0.0.1');
}
function getPool(connectionString) {
if (!globalThis.__smsExtensionFdkStoragePool) {
globalThis.__smsExtensionFdkStoragePool = new Pool({
connectionString,
max: 3,
idleTimeoutMillis: 30000,
ssl: shouldUseSsl(connectionString) ? { rejectUnauthorized: false } : undefined,
});
}
return globalThis.__smsExtensionFdkStoragePool;
}
function serializeValue(value) {
return JSON.stringify(value);
}
function parseValue(rawValue) {
if (!rawValue) return null;
if (typeof rawValue === 'object') return rawValue;
try {
return JSON.parse(rawValue);
} catch {
return null;
}
}
class PostgresFdkStorage extends BaseStorage {
constructor({ prefixKey, connectionString, tableName = DEFAULT_TABLE_NAME }) {
super(prefixKey);
if (!normalizeEnvText(connectionString)) {
throw new Error('FDK_STORAGE_CONNECTION_STRING is required for Postgres FDK storage');
}
this.pool = getPool(connectionString);
this.tableIdentifier = quoteIdentifier(tableName);
}
buildKey(key) {
return `${this.prefixKey}${key}`;
}
async get(key) {
const storageKey = this.buildKey(key);
const { rows } = await this.pool.query(
`SELECT value, expires_at
FROM ${this.tableIdentifier}
WHERE storage_key = $1
ORDER BY updated_at DESC NULLS LAST, created_at DESC NULLS LAST, id DESC
LIMIT 1`,
[storageKey]
);
const row = rows[0];
if (!row) return null;
const expiresAt = row.expires_at ? new Date(row.expires_at) : null;
if (expiresAt && expiresAt.getTime() <= Date.now()) {
await this.del(key);
return null;
}
return parseValue(row.value);
}
async set(key, value) {
await this.writeRecord(key, value, null);
return 'OK';
}
async setex(key, value, ttl) {
const expiresAt = Number.isFinite(ttl)
? new Date(Date.now() + ttl * 1000)
: null;
await this.writeRecord(key, value, expiresAt);
return 'OK';
}
async del(key) {
const storageKey = this.buildKey(key);
await this.pool.query(
`DELETE FROM ${this.tableIdentifier}
WHERE storage_key = $1`,
[storageKey]
);
}
async writeRecord(key, value, expiresAt) {
const storageKey = this.buildKey(key);
const serializedValue = serializeValue(value);
const updateResult = await this.pool.query(
`UPDATE ${this.tableIdentifier}
SET value = $2,
expires_at = $3,
updated_at = NOW()
WHERE storage_key = $1`,
[storageKey, serializedValue, expiresAt]
);
if (updateResult.rowCount > 0) return;
await this.pool.query(
`INSERT INTO ${this.tableIdentifier} (storage_key, value, expires_at)
VALUES ($1, $2, $3)`,
[storageKey, serializedValue, expiresAt]
);
}
}
function createFdkStorage({ prefixKey, connectionString }) {
if (!normalizeEnvText(connectionString)) return null;
return new PostgresFdkStorage({
prefixKey,
connectionString,
tableName: DEFAULT_TABLE_NAME,
});
}
module.exports = {
DEFAULT_TABLE_NAME,
PostgresFdkStorage,
createFdkStorage,
};