Skip to content

Instantly share code, notes, and snippets.

@jay-babu
Created October 1, 2025 22:09
Show Gist options
  • Select an option

  • Save jay-babu/0adef0c0e10a398e07b32883db796cde to your computer and use it in GitHub Desktop.

Select an option

Save jay-babu/0adef0c0e10a398e07b32883db796cde to your computer and use it in GitHub Desktop.
diff --git a/src/App.tsx b/src/App.tsx
index 1ffd3820b..f6d500439 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,10 +1,3 @@
-import CashRegister from "@/how-to-guides/cash-register.mdx";
-import DoorDashIntegration from "@/how-to-guides/doordash-integration.mdx";
-import Invoices from "@/how-to-guides/invoices.mdx";
-import ItemManagement from "@/how-to-guides/item-management.mdx";
-import PaymentProcessing from "@/how-to-guides/payment-processing.mdx";
-import PurchaseOrders from "@/how-to-guides/purchase-orders.mdx";
-import SalesChannels from "@/how-to-guides/sales-channels.mdx";
import { useColorMode } from "@chakra-ui/react";
import { MDXProvider } from "@mdx-js/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -16,7 +9,7 @@ import { SalePageConfigProvider } from "components/SalesScreenComponents/SalePag
import { SentryErrorBoundary } from "config/sentry/SentryErrorBoundary";
import { AppVersionProvider } from "context/AppVersionContext";
import { DepartmentProvider } from "context/DepartmentContext";
-import { ReactNode, Suspense, useEffect, useMemo, useState } from "react";
+import { type ReactNode, Suspense, useEffect, useMemo, useState } from "react";
import {
createBrowserRouter,
Outlet,
@@ -25,6 +18,13 @@ import {
} from "react-router-dom";
import { QueryParamProvider } from "use-query-params";
import { ReactRouter6Adapter } from "use-query-params/adapters/react-router-6";
+import CashRegister from "@/how-to-guides/cash-register.mdx";
+import DoorDashIntegration from "@/how-to-guides/doordash-integration.mdx";
+import Invoices from "@/how-to-guides/invoices.mdx";
+import ItemManagement from "@/how-to-guides/item-management.mdx";
+import PaymentProcessing from "@/how-to-guides/payment-processing.mdx";
+import PurchaseOrders from "@/how-to-guides/purchase-orders.mdx";
+import SalesChannels from "@/how-to-guides/sales-channels.mdx";
import { PermissionRequiredRoute } from "./components/Auth/PermissionRequiredRoute";
import RequiresDrawerSettlement from "./components/Auth/RequiresDrawerSettlement";
import Loading from "./components/Loading";
@@ -257,9 +257,11 @@ const router = createBrowserRouter([
<EntityContextProvider>
<SentryErrorBoundary>
<LabelPrintProvider>
- <ProtectedRoute>
- <Outlet />
- </ProtectedRoute>
+ <PGliteProvider>
+ <ProtectedRoute>
+ <Outlet />
+ </ProtectedRoute>
+ </PGliteProvider>
</LabelPrintProvider>
</SentryErrorBoundary>
</EntityContextProvider>
@@ -555,15 +557,13 @@ const router = createBrowserRouter([
element: (
<DepartmentProvider>
<SalePageConfigProvider>
- <PGliteProvider>
- <SalesContextProvider>
- <FocusDialog>
- <RequiresDrawerSettlement>
- <SalePage />
- </RequiresDrawerSettlement>
- </FocusDialog>
- </SalesContextProvider>
- </PGliteProvider>
+ <SalesContextProvider>
+ <FocusDialog>
+ <RequiresDrawerSettlement>
+ <SalePage />
+ </RequiresDrawerSettlement>
+ </FocusDialog>
+ </SalesContextProvider>
</SalePageConfigProvider>
</DepartmentProvider>
),
diff --git a/src/components/SalesScreenComponents/Modals/CartHoldsModal.tsx b/src/components/SalesScreenComponents/Modals/CartHoldsModal.tsx
index 5c7f2185e..2c87cd12c 100644
--- a/src/components/SalesScreenComponents/Modals/CartHoldsModal.tsx
+++ b/src/components/SalesScreenComponents/Modals/CartHoldsModal.tsx
@@ -18,7 +18,6 @@ import {
useToast,
VStack,
} from "@chakra-ui/react";
-import { usePGlite } from "@electric-sql/pglite-react";
import { useQueryClient } from "@tanstack/react-query";
import { DeleteButtonWithWarning } from "components/DeleteButtonWithWarning";
import {
@@ -60,6 +59,7 @@ import {
import { StyledInput } from "../../common/StyledInput";
import { ItemSearchPaginator } from "../../ItemSearchComponents/ItemSearchPaginator";
import { ItemRetriever } from "context/Sales/ItemRetriever";
+import { usePGlite } from "components/pg-lite/PGliteProvider";
export type CartHoldsModalProps = {
isOpen: boolean;
diff --git a/src/components/SalesScreenComponents/SalePageConfigProvider.tsx b/src/components/SalesScreenComponents/SalePageConfigProvider.tsx
index ebbc9ff0d..0232d4ef8 100644
--- a/src/components/SalesScreenComponents/SalePageConfigProvider.tsx
+++ b/src/components/SalesScreenComponents/SalePageConfigProvider.tsx
@@ -1,5 +1,9 @@
import { useQueryClient } from "@tanstack/react-query";
import Loading from "components/Loading";
+import {
+ ALL_TABLE_CONFIGS,
+ usePGliteSyncTables,
+} from "components/pg-lite/PGliteSyncContext";
import { useEntitySelected } from "context/EntityProvider";
import React, { useCallback, useEffect } from "react";
import { Provider, useDispatch, useSelector } from "react-redux";
@@ -23,6 +27,10 @@ import {
export const SalePageConfigProvider: React.FC<{
children: React.ReactNode;
}> = ({ children }) => {
+ usePGliteSyncTables({
+ tableConfigs: ALL_TABLE_CONFIGS,
+ });
+
return (
<Provider store={store}>
<SalePageConfigLoader>{children}</SalePageConfigLoader>
diff --git a/src/components/SalesScreenComponents/SalesButtonGroups/QuickPicks/HotKeyButton.tsx b/src/components/SalesScreenComponents/SalesButtonGroups/QuickPicks/HotKeyButton.tsx
index 036560263..0eb6cc3ff 100644
--- a/src/components/SalesScreenComponents/SalesButtonGroups/QuickPicks/HotKeyButton.tsx
+++ b/src/components/SalesScreenComponents/SalesButtonGroups/QuickPicks/HotKeyButton.tsx
@@ -1,4 +1,4 @@
-import { usePGlite } from "@electric-sql/pglite-react";
+import { usePGlite } from "components/pg-lite/PGliteProvider";
import { useQueryClient } from "@tanstack/react-query";
import {
RetrieveItemsByUpc,
diff --git a/src/components/SalesScreenComponents/SalesScreenItemsTable.tsx b/src/components/SalesScreenComponents/SalesScreenItemsTable.tsx
index ce7878e17..8149f0f73 100644
--- a/src/components/SalesScreenComponents/SalesScreenItemsTable.tsx
+++ b/src/components/SalesScreenComponents/SalesScreenItemsTable.tsx
@@ -6,7 +6,7 @@ import {
} from "@/generated";
import { WarningIcon } from "@chakra-ui/icons";
import { HStack, Icon, IconButton, Text, Tooltip } from "@chakra-ui/react";
-import { usePGlite } from "@electric-sql/pglite-react";
+import { usePGlite } from "components/pg-lite/PGliteProvider";
import { createColumnHelper } from "@tanstack/react-table";
import { CanceledError } from "axios";
import {
diff --git a/src/components/common/EraseDatabaseButton.tsx b/src/components/common/EraseDatabaseButton.tsx
index 032d05278..303592b44 100644
--- a/src/components/common/EraseDatabaseButton.tsx
+++ b/src/components/common/EraseDatabaseButton.tsx
@@ -1,4 +1,4 @@
-import { usePGlite } from "@electric-sql/pglite-react";
+import { usePGlite } from "components/pg-lite/PGliteProvider";
import { ERASE_OPFS_DB } from "components/pg-lite/PgliteOptions";
import {
AlertDialog,
diff --git a/src/components/pg-lite/PGliteProvider.tsx b/src/components/pg-lite/PGliteProvider.tsx
index 1467ca603..b05535aec 100644
--- a/src/components/pg-lite/PGliteProvider.tsx
+++ b/src/components/pg-lite/PGliteProvider.tsx
@@ -1,60 +1,21 @@
-import { PGliteProvider as _PGliteProvider } from "@electric-sql/pglite-react";
-import type {
- MapColumnsFn,
- ShapeToTableOptions,
- SyncShapesToTablesResult,
-} from "@electric-sql/pglite-sync";
import { PGliteWorker } from "@electric-sql/pglite/worker";
+import { makePGliteProvider } from "@electric-sql/pglite-react";
import Loading from "components/Loading";
import PGWorker from "components/pg-lite/pglite-worker.ts?worker";
import { firebaseAuth } from "config/Firebase/firebase";
-import { useEntity } from "context/EntityProvider";
-import { useEffect, useRef, useState } from "react";
-import { typeidUnboxed } from "typeid-js";
-import { useLocalStorage } from "usehooks-ts";
-import {
- type CustomPGlite,
- ERASE_OPFS_DB,
- PG_LITE_INITIAL_SYNC_COMPLETE,
- PGLITE_OPTIONS,
-} from "./PgliteOptions";
+import { useEffect, useState } from "react";
+import { type CustomPGlite, PGLITE_OPTIONS } from "./PgliteOptions";
-/**
- * Configuration for each table to be synced
- * name: The name of the table in the local PGlite database
- * primaryKey: The primary key columns for the table. This should match the primary key in the database as part of our abstraction.
- * Electric requires the primary key to match our shape data. Example: If the primary key is id in the DB and our prefix is ei, then the primary key is id. Our abstraction will make it ei_id.
- * columnPrefix: A prefix to add to each column name to avoid collisions
- * columns: Optional additional columns to include in the sync. These should match the column names in the database.
- * (on top of the columns returned by the shape)
- * e.g. for foreign keys or metadata
- */
-type TableConfig = {
- name: string;
- primaryKey: string[];
- columnPrefix: string;
- columns: string[]; // Optional additional columns to include
-};
+const madePGliteProvider = makePGliteProvider<CustomPGlite>();
+const _PGliteProvider = madePGliteProvider.PGliteProvider;
+export const usePGlite = madePGliteProvider.usePGlite;
const PGliteProvider = ({ children }: { children: React.ReactNode }) => {
const [client, setClient] = useState<CustomPGlite | undefined>(undefined);
- const subscriptionRef = useRef<SyncShapesToTablesResult>();
- const [entity] = useEntity();
-
- const [, setInitialSyncComplete] = useLocalStorage(
- PG_LITE_INITIAL_SYNC_COMPLETE(entity?.id ?? 0),
- false,
- );
-
useEffect(() => {
- const currentUser = firebaseAuth.currentUser;
- const getIdToken = currentUser?.getIdToken;
- if (!getIdToken) {
- console.error("No user is currently authenticated.");
- return;
- }
let worker: CustomPGlite | undefined;
+
void PGliteWorker.create(
new PGWorker({
name: "pglite-worker",
@@ -63,473 +24,17 @@ const PGliteProvider = ({ children }: { children: React.ReactNode }) => {
).then(async (pg) => {
worker = pg;
- const tableConfigs: TableConfig[] = [
- {
- name: "transaction_item_cost_type",
- primaryKey: ["type"],
- columnPrefix: "tict",
- columns: ["type"],
- },
- {
- name: "cohort",
- primaryKey: ["id"],
- columnPrefix: "co",
- columns: ["id", "name", "created_at", "updated_at", "migration_id"],
- },
- {
- name: "cohort_item_size_unit",
- primaryKey: ["size_unit"],
- columnPrefix: "cits",
- columns: ["size_unit"],
- },
- {
- name: "department_group",
- primaryKey: ["id"],
- columnPrefix: "dg",
- columns: [
- "id",
- "cohort_id",
- "name",
- "created_at",
- "updated_at",
- "migration_id",
- ],
- },
- {
- name: "department",
- primaryKey: ["id"],
- columnPrefix: "dp",
- columns: [
- "name",
- "tax",
- "bottledeposit",
- "environmentfee",
- "updated_at",
- "id",
- "cohort_id",
- "has_revenue",
- "department_group_id",
- "migration_id",
- ],
- },
- {
- name: "item_type",
- primaryKey: ["id"],
- columnPrefix: "it",
- columns: ["id", "type"],
- },
- {
- name: "cohort_item",
- primaryKey: ["id"],
- columnPrefix: "ci",
- columns: [
- "id",
- "cohort_id",
- "name",
- "case_quantity",
- "size",
- "size_unit",
- "alcohol_by_volume",
- "deposit_multiplier",
- "environment_fee_multiplier",
- "parent_cohort_item_id",
- "parent_quantity",
- "notes",
- "changed_by",
- "created_at",
- "updated_at",
- "migration_id",
- "parent_migration_id",
- "department",
- "brand",
- "pack_size",
- "alcoholic",
- "vintage",
- "item_type_id",
- "version",
- ],
- },
- {
- name: "cohort_vendor",
- primaryKey: ["id"],
- columnPrefix: "cv",
- columns: [
- "id",
- "cohort_id",
- "vendor_id",
- "vendor_name",
- "created_at",
- "updated_at",
- "changed_by",
- "email",
- "phone_number",
- "migration_id",
- "is_exclusive",
- ],
- },
- {
- name: "entity_minimum_price_source_type",
- primaryKey: ["type"],
- columnPrefix: "empst",
- columns: ["type"],
- },
- {
- name: "cohort_item_vintage",
- primaryKey: ["id"],
- columnPrefix: "civ",
- columns: [
- "id",
- "cohort_item_id",
- "vintage",
- "rating",
- "reviewer",
- "note",
- "created_at",
- "updated_at",
- "version",
- "reviewer_code",
- "cohort_id",
- ],
- },
- {
- name: "address",
- primaryKey: ["id"],
- columnPrefix: "adr",
- columns: [
- "id",
- "line1",
- "line2",
- "city",
- "state",
- "country",
- "postal_code",
- "created_at",
- "updated_at",
- ],
- },
- {
- name: "entity",
- primaryKey: ["id"],
- columnPrefix: "ey",
- columns: [
- "name",
- "id",
- "credit_card_merchant_application_id",
- "credit_card_merchant_id",
- "address",
- "fintech_host",
- "fintech_user",
- "fintech_passwd",
- "fintech_path",
- "payment_processor",
- "payment_fee_fixed",
- "payment_fee_percent",
- "updated_at",
- "phone_number",
- "cohort_id",
- "location_id",
- "timezone",
- "created_at",
- "card_not_present_payment_fee_fixed",
- "card_not_present_payment_fee_percent",
- "surcharge_fee",
- "credit_card_merchant_token",
- "credit_card_terminal_id",
- "credit_card_merchant_online_id",
- "short_name",
- "transaction_item_cost_type",
- "email",
- "address_id",
- "minimum_price_source_type",
- ],
- },
- {
- name: "entity_item",
- primaryKey: ["id"],
- columnPrefix: "ei",
- columns: [
- "id",
- "entity_id",
- "cohort_item_id",
- "price",
- "minimum_price",
- "cost",
- "discount_allowed",
- "quantity",
- "aisle_location",
- "fridge_location",
- "last_quantity_received",
- "changed_by",
- "created_at",
- "updated_at",
- "migration_id",
- "last_order_date",
- "exclusive",
- "case_cost",
- "variable_cost_percent",
- "last_cohort_vendor_id",
- "preferred_cohort_vendor_id",
- "average_cost",
- "sellable",
- "version",
- "cohort_item_vintage_id",
- "true_cost",
- ],
- },
- {
- name: "entity_tag",
- primaryKey: ["id"],
- columnPrefix: "et",
- columns: ["id", "name", "updated_at", "cohort_id", "migration_id"],
- },
- {
- name: "entity_attribute",
- primaryKey: ["id"],
- columnPrefix: "eattr",
- columns: ["id", "description"],
- },
- {
- name: "entity_configuration",
- primaryKey: ["entity_id", "attribute_id"],
- columnPrefix: "ecfg",
- columns: ["entity_id", "attribute_id", "value"],
- },
- {
- name: "register",
- primaryKey: ["entity_id", "register_number"],
- columnPrefix: "reg",
- columns: [
- "entity_id",
- "register_number",
- "credit_card_device_id",
- "updated_at",
- ],
- },
- {
- name: "bill_denomination",
- primaryKey: ["denomination_dollar_value"],
- columnPrefix: "bd",
- columns: ["denomination_dollar_value", "denomination_type"],
- },
- {
- name: "sales_channel_type",
- primaryKey: ["id"],
- columnPrefix: "sct",
- columns: ["id", "name", "description", "created_at", "config"],
- },
- {
- name: "cohort_price_level",
- primaryKey: ["id"],
- columnPrefix: "cpl",
- columns: [
- "id",
- "cohort_id",
- "name",
- "markup_percent",
- "markup_cohort_price_level_id",
- "migration_id",
- ],
- },
- {
- name: "entity_sales_channel",
- primaryKey: ["id"],
- columnPrefix: "esc",
- columns: [
- "id",
- "sales_channel_type_id",
- "entity_id",
- "created_at",
- "updated_at",
- "account_id",
- "active",
- "external_location_id",
- "name",
- "taxable",
- "price_level_id",
- "external_integration_enabled",
- "external_integration_attempt",
- "external_address_line_1",
- "external_address_line_2",
- "external_address_city",
- "external_address_state",
- "external_address_zip",
- "external_requestor_name",
- "external_requestor_email",
- "external_merchant_decision_maker_email",
- "sftp_credentials",
- "configuration",
- "auto_add_new_departments",
- "allow_negative_inventory",
- ],
- },
- {
- name: "cohort_item_tag",
- primaryKey: ["id"],
- columnPrefix: "cit",
- columns: [
- "id",
- "cohort_item_id",
- "tag_id",
- "updated_at",
- "created_at",
- "changed_by",
- "cohort_id",
- ],
- },
- {
- name: "cohort_item_barcode",
- primaryKey: ["cohort_item_id", "barcode"],
- columnPrefix: "cib",
- columns: ["cohort_item_id", "barcode", "created_at", "cohort_id"],
- },
- {
- name: "cohort_item_vendor_item",
- primaryKey: ["cohort_item_id", "cohort_vendor_id"],
- columnPrefix: "civi",
- columns: [
- "cohort_item_id",
- "vendor",
- "sku",
- "updated_at",
- "created_at",
- "changed_by",
- "cohort_vendor_id",
- "cohort_id",
- ],
- },
- {
- name: "entity_sales_channel_item",
- primaryKey: ["id"],
- columnPrefix: "esci",
- columns: [
- "id",
- "entity_sales_channel_id",
- "entity_item_id",
- "active",
- "created_at",
- "updated_at",
- "external_item_id",
- "entity_id",
- ],
- },
- {
- name: "casbin_rule_role",
- primaryKey: ["id"],
- columnPrefix: "crr",
- columns: ["id", "ptype", "v0", "v1", "v2", "v3", "v4", "v5"],
- },
- {
- name: "casbin_rule_user",
- primaryKey: ["id"],
- columnPrefix: "cru",
- columns: ["id", "ptype", "v0", "v1", "v2", "v3", "v4", "v5"],
- },
- // { name: "drawer_settlement", primaryKey: ["id"] },
- // { name: "transactionclosings", primaryKey: ["id"] },
- // Add more table configurations here as needed
- ];
-
- const shapes: Record<string, ShapeToTableOptions> = {};
-
- const electricUrl = import.meta.env.VITE_APP_ZEUS_URL;
-
- tableConfigs.forEach((config) => {
- const defaultMapColumnsPrefixed = (
- message: Parameters<MapColumnsFn>[0],
- prefix?: string,
- ) => {
- if (prefix) {
- return Object.fromEntries(
- Object.entries(message.value).map(([key, value]) => [
- `${prefix}_${key}`,
- value,
- ]),
- );
- }
- return { ...message.value };
- };
-
- const defaultMapColumns: MapColumnsFn = (message) => {
- return defaultMapColumnsPrefixed(message, config.columnPrefix);
- };
-
- shapes[config.name] = {
- shape: {
- url: `${electricUrl}/v1/shape`,
- headers: {
- // Token will be refreshed on each request
- Authorization: async () =>
- `Bearer ${await firebaseAuth.currentUser?.getIdToken()}`,
- "X-Request-Id": () => typeidUnboxed<"req">("req"),
- },
- backoffOptions: {
- initialDelay: 10000, // Start with a 10 second delay
- maxDelay: 60000 * 5, // Maximum delay of 5 minute
- multiplier: 5, // Exponential backoff factor
- },
- params: {
- table: config.name,
- replica: "full",
- columns: config.columns,
- entityId: entity?.id.toString(),
- },
- },
- table: config.name,
- mapColumns: defaultMapColumns,
- // Electric requires the primary key to match our shape data. Example: If the primary key is id in the DB and our prefix is ei, then the primary key is id. Our abstraction will make it ei_id.
- primaryKey: config.primaryKey.map((col) => {
- if (config.columnPrefix) {
- return `${config.columnPrefix}_${col}`;
- }
- return col;
- }),
- onMustRefetch: async (tx) => {
- console.log(`Refetching shape for table: ${config.name}`);
- try {
- await tx.exec(`
- SET session_replication_role = 'replica';
- `);
-
- // TRUNCATE was tried and it caused issues with electric needed to refetch constantly.
- // So we went with DELETE which is slower but safer and stable.
- await tx.exec(`DELETE FROM "${config.name}";`);
- } catch (error) {
- console.error(`Error clearing table ${config.name}:`, error);
- await ERASE_OPFS_DB(pg, entity?.id ?? 0);
- }
- },
- };
- });
-
- const sub = await pg.electric.syncShapesToTables({
- shapes: shapes,
- key: `non-null`, // https://pglite.dev/docs/sync#syncshapestotables-api
- initialInsertMethod: "csv",
- onInitialSync: async () => {
- console.log("Initial sync complete");
- setInitialSyncComplete(true);
- await pg.exec(`SET session_replication_role = 'origin';`);
- },
- });
-
- subscriptionRef.current = sub;
-
setClient(pg);
});
return () => {
- console.log("Cleaning up PGlite worker");
- if (subscriptionRef.current) {
- console.log("Unsubscribing from PGlite sync");
- subscriptionRef.current.unsubscribe();
- }
if (worker) {
void worker.close().then(() => {
console.log("PGlite worker closed");
});
}
};
- }, [entity?.id, setInitialSyncComplete]);
+ }, []);
if (!client) {
return <Loading />;
@@ -538,8 +43,4 @@ const PGliteProvider = ({ children }: { children: React.ReactNode }) => {
return <_PGliteProvider db={client}>{children}</_PGliteProvider>;
};
-const PGlineRolloutProvider = ({ children }: { children: React.ReactNode }) => {
- return <PGliteProvider>{children}</PGliteProvider>;
-};
-
-export default PGlineRolloutProvider;
+export default PGliteProvider;
diff --git a/src/components/pg-lite/PGliteSyncContext.tsx b/src/components/pg-lite/PGliteSyncContext.tsx
index cd84fe635..1760ebca6 100644
--- a/src/components/pg-lite/PGliteSyncContext.tsx
+++ b/src/components/pg-lite/PGliteSyncContext.tsx
@@ -1,12 +1,41 @@
+import type {
+ MapColumnsFn,
+ ShapeToTableOptions,
+ SyncShapesToTablesResult,
+} from "@electric-sql/pglite-sync";
+import { firebaseAuth } from "config/Firebase/firebase";
import { useEntity } from "context/EntityProvider";
+import { startOfMonth } from "date-fns";
+import { useEffect, useRef } from "react";
+import { typeidUnboxed } from "typeid-js";
import { useLocalStorage } from "usehooks-ts";
-import { PG_LITE_INITIAL_SYNC_COMPLETE } from "./PgliteOptions";
+import { usePGlite } from "./PGliteProvider";
+import { ERASE_OPFS_DB, PG_LITE_INITIAL_SYNC_COMPLETE } from "./PgliteOptions";
+
+/**
+ * Configuration for each table to be synced
+ * name: The name of the table in the local PGlite database
+ * primaryKey: The primary key columns for the table. This should match the primary key in the database as part of our abstraction.
+ * Electric requires the primary key to match our shape data. Example: If the primary key is id in the DB and our prefix is ei, then the primary key is id. Our abstraction will make it ei_id.
+ * columnPrefix: A prefix to add to each column name to avoid collisions
+ * columns: Optional additional columns to include in the sync. These should match the column names in the database.
+ * (on top of the columns returned by the shape)
+ * e.g. for foreign keys or metadata
+ */
+type TableConfig = {
+ name: string;
+ primaryKey: string[];
+ columnPrefix: string;
+ columns: string[]; // Optional additional columns to include
+};
+
+let electricAbortController: AbortController = new AbortController();
export const usePGliteSync = () => {
const [entity] = useEntity();
const [initialSyncComplete] = useLocalStorage(
PG_LITE_INITIAL_SYNC_COMPLETE(entity?.id ?? 0),
- false,
+ true,
);
console.log("initialSyncComplete =", initialSyncComplete);
@@ -15,3 +44,626 @@ export const usePGliteSync = () => {
isSyncing: !initialSyncComplete,
};
};
+
+export const ALL_TABLE_CONFIGS: TableConfig[] = [
+ {
+ name: "transaction_item_cost_type",
+ primaryKey: ["type"],
+ columnPrefix: "tict",
+ columns: ["type"],
+ },
+ {
+ name: "cohort",
+ primaryKey: ["id"],
+ columnPrefix: "co",
+ columns: ["id", "name", "created_at", "updated_at", "migration_id"],
+ },
+ {
+ name: "cohort_item_size_unit",
+ primaryKey: ["size_unit"],
+ columnPrefix: "cits",
+ columns: ["size_unit"],
+ },
+ {
+ name: "department_group",
+ primaryKey: ["id"],
+ columnPrefix: "dg",
+ columns: [
+ "id",
+ "cohort_id",
+ "name",
+ "created_at",
+ "updated_at",
+ "migration_id",
+ ],
+ },
+ {
+ name: "department",
+ primaryKey: ["id"],
+ columnPrefix: "dp",
+ columns: [
+ "name",
+ "tax",
+ "bottledeposit",
+ "environmentfee",
+ "updated_at",
+ "id",
+ "cohort_id",
+ "has_revenue",
+ "department_group_id",
+ "migration_id",
+ ],
+ },
+ {
+ name: "item_type",
+ primaryKey: ["id"],
+ columnPrefix: "it",
+ columns: ["id", "type"],
+ },
+ {
+ name: "cohort_item",
+ primaryKey: ["id"],
+ columnPrefix: "ci",
+ columns: [
+ "id",
+ "cohort_id",
+ "name",
+ "case_quantity",
+ "size",
+ "size_unit",
+ "alcohol_by_volume",
+ "deposit_multiplier",
+ "environment_fee_multiplier",
+ "parent_cohort_item_id",
+ "parent_quantity",
+ "notes",
+ "changed_by",
+ "created_at",
+ "updated_at",
+ "migration_id",
+ "parent_migration_id",
+ "department",
+ "brand",
+ "pack_size",
+ "alcoholic",
+ "vintage",
+ "item_type_id",
+ "version",
+ ],
+ },
+ {
+ name: "cohort_vendor",
+ primaryKey: ["id"],
+ columnPrefix: "cv",
+ columns: [
+ "id",
+ "cohort_id",
+ "vendor_id",
+ "vendor_name",
+ "created_at",
+ "updated_at",
+ "changed_by",
+ "email",
+ "phone_number",
+ "migration_id",
+ "is_exclusive",
+ ],
+ },
+ {
+ name: "entity_minimum_price_source_type",
+ primaryKey: ["type"],
+ columnPrefix: "empst",
+ columns: ["type"],
+ },
+ {
+ name: "cohort_item_vintage",
+ primaryKey: ["id"],
+ columnPrefix: "civ",
+ columns: [
+ "id",
+ "cohort_item_id",
+ "vintage",
+ "rating",
+ "reviewer",
+ "note",
+ "created_at",
+ "updated_at",
+ "version",
+ "reviewer_code",
+ "cohort_id",
+ ],
+ },
+ {
+ name: "address",
+ primaryKey: ["id"],
+ columnPrefix: "adr",
+ columns: [
+ "id",
+ "line1",
+ "line2",
+ "city",
+ "state",
+ "country",
+ "postal_code",
+ "created_at",
+ "updated_at",
+ ],
+ },
+ {
+ name: "entity",
+ primaryKey: ["id"],
+ columnPrefix: "ey",
+ columns: [
+ "name",
+ "id",
+ "credit_card_merchant_application_id",
+ "credit_card_merchant_id",
+ "address",
+ "fintech_host",
+ "fintech_user",
+ "fintech_passwd",
+ "fintech_path",
+ "payment_processor",
+ "payment_fee_fixed",
+ "payment_fee_percent",
+ "updated_at",
+ "phone_number",
+ "cohort_id",
+ "location_id",
+ "timezone",
+ "created_at",
+ "card_not_present_payment_fee_fixed",
+ "card_not_present_payment_fee_percent",
+ "surcharge_fee",
+ "credit_card_merchant_token",
+ "credit_card_terminal_id",
+ "credit_card_merchant_online_id",
+ "short_name",
+ "transaction_item_cost_type",
+ "email",
+ "address_id",
+ "minimum_price_source_type",
+ ],
+ },
+ {
+ name: "entity_item",
+ primaryKey: ["id"],
+ columnPrefix: "ei",
+ columns: [
+ "id",
+ "entity_id",
+ "cohort_item_id",
+ "price",
+ "minimum_price",
+ "cost",
+ "discount_allowed",
+ "quantity",
+ "aisle_location",
+ "fridge_location",
+ "last_quantity_received",
+ "changed_by",
+ "created_at",
+ "updated_at",
+ "migration_id",
+ "last_order_date",
+ "exclusive",
+ "case_cost",
+ "variable_cost_percent",
+ "last_cohort_vendor_id",
+ "preferred_cohort_vendor_id",
+ "average_cost",
+ "sellable",
+ "version",
+ "cohort_item_vintage_id",
+ "true_cost",
+ ],
+ },
+ {
+ name: "entity_tag",
+ primaryKey: ["id"],
+ columnPrefix: "et",
+ columns: ["id", "name", "updated_at", "cohort_id", "migration_id"],
+ },
+ {
+ name: "entity_attribute",
+ primaryKey: ["id"],
+ columnPrefix: "eattr",
+ columns: ["id", "description"],
+ },
+ {
+ name: "entity_configuration",
+ primaryKey: ["entity_id", "attribute_id"],
+ columnPrefix: "ecfg",
+ columns: ["entity_id", "attribute_id", "value"],
+ },
+ {
+ name: "register",
+ primaryKey: ["entity_id", "register_number"],
+ columnPrefix: "reg",
+ columns: [
+ "entity_id",
+ "register_number",
+ "credit_card_device_id",
+ "updated_at",
+ ],
+ },
+ {
+ name: "bill_denomination",
+ primaryKey: ["denomination_dollar_value"],
+ columnPrefix: "bd",
+ columns: ["denomination_dollar_value", "denomination_type"],
+ },
+ {
+ name: "sales_channel_type",
+ primaryKey: ["id"],
+ columnPrefix: "sct",
+ columns: ["id", "name", "description", "created_at", "config"],
+ },
+ {
+ name: "cohort_price_level",
+ primaryKey: ["id"],
+ columnPrefix: "cpl",
+ columns: [
+ "id",
+ "cohort_id",
+ "name",
+ "markup_percent",
+ "markup_cohort_price_level_id",
+ "migration_id",
+ ],
+ },
+ {
+ name: "entity_sales_channel",
+ primaryKey: ["id"],
+ columnPrefix: "esc",
+ columns: [
+ "id",
+ "sales_channel_type_id",
+ "entity_id",
+ "created_at",
+ "updated_at",
+ "account_id",
+ "active",
+ "external_location_id",
+ "name",
+ "taxable",
+ "price_level_id",
+ "external_integration_enabled",
+ "external_integration_attempt",
+ "external_address_line_1",
+ "external_address_line_2",
+ "external_address_city",
+ "external_address_state",
+ "external_address_zip",
+ "external_requestor_name",
+ "external_requestor_email",
+ "external_merchant_decision_maker_email",
+ "sftp_credentials",
+ "configuration",
+ "auto_add_new_departments",
+ "allow_negative_inventory",
+ ],
+ },
+ {
+ name: "cohort_item_tag",
+ primaryKey: ["id"],
+ columnPrefix: "cit",
+ columns: [
+ "id",
+ "cohort_item_id",
+ "tag_id",
+ "updated_at",
+ "created_at",
+ "changed_by",
+ "cohort_id",
+ ],
+ },
+ {
+ name: "cohort_item_barcode",
+ primaryKey: ["cohort_item_id", "barcode"],
+ columnPrefix: "cib",
+ columns: ["cohort_item_id", "barcode", "created_at", "cohort_id"],
+ },
+ {
+ name: "cohort_item_vendor_item",
+ primaryKey: ["cohort_item_id", "cohort_vendor_id"],
+ columnPrefix: "civi",
+ columns: [
+ "cohort_item_id",
+ "vendor",
+ "sku",
+ "updated_at",
+ "created_at",
+ "changed_by",
+ "cohort_vendor_id",
+ "cohort_id",
+ ],
+ },
+ {
+ name: "entity_sales_channel_item",
+ primaryKey: ["id"],
+ columnPrefix: "esci",
+ columns: [
+ "id",
+ "entity_sales_channel_id",
+ "entity_item_id",
+ "active",
+ "created_at",
+ "updated_at",
+ "external_item_id",
+ "entity_id",
+ ],
+ },
+ {
+ name: "casbin_rule_role",
+ primaryKey: ["id"],
+ columnPrefix: "crr",
+ columns: ["id", "ptype", "v0", "v1", "v2", "v3", "v4", "v5"],
+ },
+ {
+ name: "casbin_rule_user",
+ primaryKey: ["id"],
+ columnPrefix: "cru",
+ columns: ["id", "ptype", "v0", "v1", "v2", "v3", "v4", "v5"],
+ },
+ {
+ name: "promotion",
+ primaryKey: ["id"],
+ columnPrefix: "pr",
+ columns: [
+ "id",
+ "name",
+ "type",
+ "start_date",
+ "end_date",
+ "updated_at",
+ "cohort_id",
+ "requires_customer",
+ "stackable_on_same_item",
+ "max_discountable_items",
+ "size",
+ "size_unit",
+ "enforce_min_bottle",
+ "requires_employee",
+ ],
+ },
+ {
+ name: "cohort_price_level_item",
+ primaryKey: ["id"],
+ columnPrefix: "cpli",
+ columns: [
+ "id",
+ "cohort_price_level_id",
+ "cohort_item_id",
+ "price",
+ "cohort_id",
+ ],
+ },
+ {
+ name: "promotion_bulk_pricing",
+ primaryKey: ["promotion_id", "quantity"],
+ columnPrefix: "pbp",
+ columns: [
+ "promotion_id",
+ "quantity",
+ "value",
+ "updated_at",
+ "type",
+ "cohort_id",
+ ],
+ },
+ {
+ name: "promotion_entity_sales_channel",
+ primaryKey: ["id"],
+ columnPrefix: "pesc",
+ columns: [
+ "id",
+ "promotion_id",
+ "entity_sales_channel_id",
+ "created_at",
+ "updated_at",
+ "cohort_id",
+ ],
+ },
+ {
+ name: "promotion_item",
+ primaryKey: ["id"],
+ columnPrefix: "pri",
+ columns: [
+ "id",
+ "promotion_id",
+ "tag_id",
+ "type",
+ "updated_at",
+ "cohort_item_id",
+ "department",
+ "cohort_id",
+ ],
+ },
+ {
+ name: "promotion_schedule",
+ primaryKey: ["promotion_id", "day_of_week", "start_at", "end_at"],
+ columnPrefix: "prs",
+ columns: [
+ "promotion_id",
+ "day_of_week",
+ "start_at",
+ "end_at",
+ "created_at",
+ "cohort_id",
+ ],
+ },
+ {
+ name: "loyalty_program",
+ primaryKey: ["id"],
+ columnPrefix: "lp",
+ columns: [
+ "id",
+ "description",
+ "cohort_id",
+ "created_at",
+ "updated_at",
+ "send_email",
+ "send_text",
+ "text_show_rank",
+ "text_show_total_points",
+ "text_show_rebates",
+ "points_rounding_mode",
+ "lookback_period",
+ "lookback_truncate_to",
+ "provider",
+ "phone_number",
+ "phone_registration_status",
+ "phone_registration",
+ "text_show_earned_points",
+ "default_create_house_account_across_entities",
+ ],
+ },
+ {
+ name: "loyalty_customer",
+ primaryKey: ["id"],
+ columnPrefix: "lc",
+ columns: [
+ "id",
+ "customer_id",
+ "loyalty_program_id",
+ "created_at",
+ "lookup_id",
+ "display_id",
+ "send_email",
+ "send_text",
+ "note",
+ "tax_exempt",
+ "price_at_cost",
+ "fixed_discount",
+ "is_employee",
+ "line_item_fixed_discount",
+ "firebase_uid",
+ "cohort_id",
+ ],
+ },
+ // { name: "drawer_settlement", primaryKey: ["id"] },
+ // { name: "transactionclosings", primaryKey: ["id"] },
+ // Add more table configurations here as needed
+];
+
+export type UsePGliteSyncTablesProps = {
+ tableConfigs?: TableConfig[];
+};
+
+export const usePGliteSyncTables = ({
+ tableConfigs = [],
+}: UsePGliteSyncTablesProps) => {
+ const pg = usePGlite();
+ const subscriptionRef = useRef<SyncShapesToTablesResult>();
+ const [entity] = useEntity();
+ const [, setInitialSyncComplete] = useLocalStorage(
+ PG_LITE_INITIAL_SYNC_COMPLETE(entity?.id ?? 0),
+ false,
+ );
+
+ useEffect(() => {
+ const shapes: Record<string, ShapeToTableOptions> = {};
+
+ const electricUrl = import.meta.env.VITE_APP_ZEUS_URL;
+
+ electricAbortController.abort();
+ electricAbortController = new AbortController();
+
+ tableConfigs.forEach((config) => {
+ const defaultMapColumnsPrefixed = (
+ message: Parameters<MapColumnsFn>[0],
+ prefix?: string,
+ ) => {
+ if (prefix) {
+ return Object.fromEntries(
+ Object.entries(message.value).map(([key, value]) => [
+ `${prefix}_${key}`,
+ value,
+ ]),
+ );
+ }
+ return { ...message.value };
+ };
+
+ const defaultMapColumns: MapColumnsFn = (message) => {
+ return defaultMapColumnsPrefixed(message, config.columnPrefix);
+ };
+
+ shapes[config.name] = {
+ shape: {
+ url: `${electricUrl}/v1/shape`,
+ headers: {
+ // Token will be refreshed on each request
+ Authorization: async () =>
+ `Bearer ${await firebaseAuth.currentUser?.getIdToken()}`,
+ "X-Request-Id": () => typeidUnboxed<"req">("req"),
+ },
+ experimentalLiveSse: true,
+ signal: electricAbortController.signal,
+ backoffOptions: {
+ initialDelay: 10000, // Start with a 10 second delay
+ maxDelay: 60000 * 5, // Maximum delay of 5 minute
+ multiplier: 5, // Exponential backoff factor
+ },
+ params: {
+ table: config.name,
+ replica: "full",
+ columns: config.columns,
+ entityId: entity?.id.toString(),
+ monthDate: startOfMonth(new Date()).toISOString(),
+ },
+ },
+ table: config.name,
+ mapColumns: defaultMapColumns,
+ // Electric requires the primary key to match our shape data. Example: If the primary key is id in the DB and our prefix is ei, then the primary key is id. Our abstraction will make it ei_id.
+ primaryKey: config.primaryKey.map((col) => {
+ if (config.columnPrefix) {
+ return `${config.columnPrefix}_${col}`;
+ }
+ return col;
+ }),
+ onMustRefetch: async (tx) => {
+ console.log(`Refetching shape for table: ${config.name}`);
+ try {
+ await tx.exec(`
+ SET session_replication_role = 'replica';
+ `);
+
+ // TRUNCATE was tried and it caused issues with electric needed to refetch constantly.
+ // So we went with DELETE which is slower but safer and stable.
+ await tx.exec(`DELETE FROM "${config.name}";`);
+ } catch (error) {
+ console.error(`Error clearing table ${config.name}:`, error);
+ await ERASE_OPFS_DB(pg, entity?.id ?? 0);
+ }
+ },
+ };
+ });
+
+ void pg.electric
+ .syncShapesToTables({
+ shapes: shapes,
+ key: `non-null`, // https://pglite.dev/docs/sync#syncshapestotables-api
+ initialInsertMethod: "csv",
+ onInitialSync: async () => {
+ console.log("Initial sync complete");
+ setInitialSyncComplete(true);
+ await pg.exec(`SET session_replication_role = 'origin';`);
+ },
+ })
+ .then((sub) => {
+ subscriptionRef.current = sub;
+ });
+
+ return () => {
+ console.log("Cleaning up PGlite worker");
+ if (subscriptionRef.current) {
+ console.log("Unsubscribing from PGlite sync");
+ subscriptionRef.current.unsubscribe();
+ }
+
+ electricAbortController.abort();
+ };
+ }, [entity?.id, pg, setInitialSyncComplete, tableConfigs]);
+};
diff --git a/src/components/promise/PromiseTimeout.test.ts b/src/components/promise/PromiseTimeout.test.ts
new file mode 100644
index 000000000..f5ac74f8f
--- /dev/null
+++ b/src/components/promise/PromiseTimeout.test.ts
@@ -0,0 +1,161 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { promiseTimeout } from "./PromiseTimeout";
+
+describe("promiseTimeout", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("should resolve with the promise value when promise resolves before timeout", async () => {
+ const expectedValue = "success";
+ const promise = Promise.resolve(expectedValue);
+ const timeoutMs = 1000;
+
+ const result = await promiseTimeout(promise, timeoutMs);
+
+ expect(result).toBe(expectedValue);
+ });
+
+ it("should resolve with the promise value when promise resolves exactly at timeout", async () => {
+ const expectedValue = 42;
+ const timeoutMs = 1000;
+
+ const promise = new Promise<number>((resolve) => {
+ setTimeout(() => resolve(expectedValue), timeoutMs);
+ });
+
+ const resultPromise = promiseTimeout(promise, timeoutMs);
+
+ // Advance timers to exactly the timeout
+ vi.advanceTimersByTime(timeoutMs);
+
+ const result = await resultPromise;
+ expect(result).toBe(expectedValue);
+ });
+
+ it("should reject with timeout error when promise takes longer than timeout", async () => {
+ const timeoutMs = 500;
+
+ const slowPromise = new Promise<string>((resolve) => {
+ setTimeout(() => resolve("too late"), timeoutMs + 100);
+ });
+
+ const resultPromise = promiseTimeout(slowPromise, timeoutMs);
+
+ // Advance timers past the timeout but before the slow promise resolves
+ vi.advanceTimersByTime(timeoutMs + 1);
+
+ await expect(resultPromise).rejects.toThrow("timeout");
+ });
+
+ it("should reject with original error when promise rejects before timeout", async () => {
+ const originalError = new Error("original error");
+ const rejectedPromise = Promise.reject(originalError);
+ const timeoutMs = 1000;
+
+ await expect(promiseTimeout(rejectedPromise, timeoutMs)).rejects.toThrow(
+ "original error",
+ );
+ });
+
+ it("should reject with timeout error when promise rejects after timeout", async () => {
+ const timeoutMs = 500;
+
+ const slowRejectingPromise = new Promise<string>((_, reject) => {
+ setTimeout(() => reject(new Error("slow rejection")), timeoutMs + 100);
+ });
+
+ const resultPromise = promiseTimeout(slowRejectingPromise, timeoutMs);
+
+ // Advance timers past the timeout
+ vi.advanceTimersByTime(timeoutMs + 1);
+
+ await expect(resultPromise).rejects.toThrow("timeout");
+ });
+
+ it("should handle zero timeout", async () => {
+ const promise = new Promise<string>((resolve) => {
+ setTimeout(() => resolve("delayed"), 1);
+ });
+
+ const resultPromise = promiseTimeout(promise, 0);
+
+ // Even with zero timeout, we need to advance timers to trigger the timeout
+ vi.advanceTimersByTime(1);
+
+ await expect(resultPromise).rejects.toThrow("timeout");
+ });
+
+ it("should handle negative timeout", async () => {
+ const promise = Promise.resolve("immediate");
+
+ // Negative timeout should still work (setTimeout treats negative as 0)
+ const resultPromise = promiseTimeout(promise, -100);
+
+ vi.advanceTimersByTime(1);
+
+ // The immediate promise should resolve before the timeout fires
+ const result = await resultPromise;
+ expect(result).toBe("immediate");
+ });
+
+ it("should work with different data types", async () => {
+ // Test with object
+ const objectPromise = Promise.resolve({ id: 1, name: "test" });
+ const objectResult = await promiseTimeout(objectPromise, 1000);
+ expect(objectResult).toEqual({ id: 1, name: "test" });
+
+ // Test with array
+ const arrayPromise = Promise.resolve([1, 2, 3]);
+ const arrayResult = await promiseTimeout(arrayPromise, 1000);
+ expect(arrayResult).toEqual([1, 2, 3]);
+
+ // Test with null
+ const nullPromise = Promise.resolve(null);
+ const nullResult = await promiseTimeout(nullPromise, 1000);
+ expect(nullResult).toBeNull();
+
+ // Test with undefined
+ const undefinedPromise = Promise.resolve(undefined);
+ const undefinedResult = await promiseTimeout(undefinedPromise, 1000);
+ expect(undefinedResult).toBeUndefined();
+ });
+
+ it("should maintain promise chain behavior", async () => {
+ const initialValue = 5;
+ const promise = Promise.resolve(initialValue)
+ .then((val) => val * 2)
+ .then((val) => val.toString());
+
+ const result = await promiseTimeout(promise, 1000);
+ expect(result).toBe("10");
+ });
+
+ it("should handle concurrent timeouts with different durations", async () => {
+ const fastPromise = new Promise<string>((resolve) => {
+ setTimeout(() => resolve("fast"), 100);
+ });
+
+ const slowPromise = new Promise<string>((resolve) => {
+ setTimeout(() => resolve("slow"), 300);
+ });
+
+ const fastTimeoutPromise = promiseTimeout(fastPromise, 200);
+ const slowTimeoutPromise = promiseTimeout(slowPromise, 200);
+
+ // Advance to 150ms - fast should resolve, slow should not
+ vi.advanceTimersByTime(150);
+
+ const fastResult = await fastTimeoutPromise;
+ expect(fastResult).toBe("fast");
+
+ // Advance to 250ms total - slow should timeout
+ vi.advanceTimersByTime(100);
+
+ await expect(slowTimeoutPromise).rejects.toThrow("timeout");
+ });
+});
diff --git a/src/components/promise/PromiseTimeout.ts b/src/components/promise/PromiseTimeout.ts
new file mode 100644
index 000000000..a6415c3f1
--- /dev/null
+++ b/src/components/promise/PromiseTimeout.ts
@@ -0,0 +1,13 @@
+/**
+ * The timeoutPromise helper allows you to wrap any promise to fulfill within a timeout.
+ */
+export const promiseTimeout = <T>(
+ promise: Promise<T>,
+ timeoutInMilliseconds: number,
+): Promise<T> =>
+ Promise.race<T>([
+ promise,
+ new Promise<T>((_, reject) => {
+ setTimeout(() => reject(new Error("timeout")), timeoutInMilliseconds);
+ }),
+ ]);
diff --git a/src/context/Sales/ItemRetriever.ts b/src/context/Sales/ItemRetriever.ts
index 9a220e761..8ecea29b7 100644
--- a/src/context/Sales/ItemRetriever.ts
+++ b/src/context/Sales/ItemRetriever.ts
@@ -4,17 +4,18 @@ import {
type RetrieveItemsByUpc,
RetrieveItemsByUpcQuery,
} from "components/pg-lite/queries/RetrieveItemsByUpc";
+import type { EntityItem } from "components/pg-lite/table-types/EntityItem";
+import { promiseTimeout } from "components/promise/PromiseTimeout";
import { captureException } from "config/sentry/TransformitySentry";
import { isEqual } from "lodash-es";
import posthog from "posthog-js";
import {
- EntityItemDetail,
- type RetrieveItemsLikeUpcQueryResponse,
+ type EntityItemDetail,
getItemByIdQueryOptions,
+ type RetrieveItemsLikeUpcQueryResponse,
retrieveItemsLikeUpcQueryOptions,
} from "src/generated";
import { ItemMapper } from "./ItemMapper";
-import { EntityItem } from "components/pg-lite/table-types/EntityItem";
export interface ItemRetrieverDependencies {
db: PGliteWithLive; // PGlite database instance
@@ -54,35 +55,55 @@ export class ItemRetriever {
): Promise<ItemRetrieverResult> {
const startTime = performance.now();
- // Query PGLite first
+ // Query PGLite first with timeout handling
const options = RetrieveItemsByUpcQuery(
this.entityId,
exact ? barCode : `%${barCode}%`,
null,
);
console.log("options", options.query, options.params);
- const eRes = await this.db.query<RetrieveItemsByUpc>(
- options.query,
- options.params,
- );
+
+ let eRes: RetrieveItemsByUpc[] = [];
+
+ try {
+ eRes = (
+ await promiseTimeout(
+ this.db.query<RetrieveItemsByUpc>(options.query, options.params),
+ 1000,
+ )
+ ).rows;
+ } catch (error) {
+ // Handle timeout or other PGLite errors
+ if (error instanceof Error && error.message === "timeout") {
+ console.warn("PGLite query timed out, falling back to backend");
+
+ posthog.capture("pg_lite_timeout_fallback_to_backend", {
+ entityId: this.entityId,
+ upcCode: exact ? barCode : `%${barCode}%`,
+ });
+ } else {
+ console.error("PGLite query failed:", error);
+ captureException(error);
+ }
+ }
let backendRetrieveRes: RetrieveItemsLikeUpcQueryResponse | undefined;
- // Only query backend if PGLite returned no results
+ // Query backend if PGLite returned no results OR if PGLite timed out
try {
const opt = retrieveItemsLikeUpcQueryOptions({
entityId: this.entityId,
upcCode: exact ? barCode : `%${barCode}%`,
});
- if (eRes.rows.length === 0) {
+ if (eRes.length === 0) {
backendRetrieveRes = await this.queryClient
.fetchQuery(opt)
.then((res) => {
- // Track discrepancies between pg-lite and spring backend
+ // Track discrepancies between pg-lite and spring backend (only if PGLite didn't timeout)
if (
!isEqual(
res.map((r) => r.id).toSorted((a: number, b: number) => a - b),
- eRes.rows
+ eRes
.map((r) => r.ei_id)
.toSorted((a: number, b: number) => a - b),
)
@@ -90,12 +111,13 @@ export class ItemRetriever {
posthog.capture("pg_lite_spring_backend_discrepancy", {
entityId: this.entityId,
upcCode: exact ? barCode : `%${barCode}%`,
- pgLiteCount: eRes.rows.length,
+ pgLiteCount: eRes.length,
springBackendCount: res.length,
- pgLiteItems: eRes.rows,
+ pgLiteItems: eRes,
springBackendItems: res,
});
}
+
return res;
});
}
@@ -105,8 +127,8 @@ export class ItemRetriever {
// Determine final items to return
const finalItems: RetrieveItemsByUpc[] = (() => {
- if (eRes.rows.length >= 1) {
- return eRes.rows;
+ if (eRes.length >= 1) {
+ return eRes;
} else if (backendRetrieveRes && backendRetrieveRes.length >= 1) {
return ItemMapper.mapBackendResponseToRetrieveItemsByUpc(
backendRetrieveRes,
@@ -122,7 +144,7 @@ export class ItemRetriever {
);
return {
- pgLiteItems: eRes.rows,
+ pgLiteItems: eRes,
backendItems: backendRetrieveRes,
finalItems,
performanceMs: endTime - startTime,
diff --git a/src/context/Sales/SalesContextProvider.tsx b/src/context/Sales/SalesContextProvider.tsx
index 67d73a619..19e9e8722 100644
--- a/src/context/Sales/SalesContextProvider.tsx
+++ b/src/context/Sales/SalesContextProvider.tsx
@@ -1,5 +1,5 @@
import { useToast } from "@chakra-ui/react";
-import { usePGlite } from "@electric-sql/pglite-react";
+import { usePGlite } from "components/pg-lite/PGliteProvider";
import { useQueryClient } from "@tanstack/react-query";
import { useDebounce } from "@uidotdev/usehooks";
import { CanceledError, isAxiosError } from "axios";
diff --git a/src/index.tsx b/src/index.tsx
index 2367ecfd5..cbb5aab74 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -12,7 +12,7 @@ import { StatsigSessionReplayPlugin } from "@statsig/session-replay";
import { StatsigAutoCapturePlugin } from "@statsig/web-analytics";
import { PostHogConfig } from "posthog-js";
import { PostHogProvider } from "posthog-js/react";
-import { ReactNode, useEffect } from "react";
+import { ReactNode, StrictMode, useEffect } from "react";
import {
createRoutesFromChildren,
matchRoutes,
@@ -176,18 +176,20 @@ const StatsigProviderWrapper = ({ children }: { children: ReactNode }) => {
};
root.render(
- <ChakraProvider
- toastOptions={{ defaultOptions: { position: "top" } }}
- theme={theme}
- >
- <StatsigProviderWrapper>
- <PostHogProviderWrapper>
- <SentryErrorBoundary>
- <App />
- <Toaster />
- <SonnerToaster />
- </SentryErrorBoundary>
- </PostHogProviderWrapper>
- </StatsigProviderWrapper>
- </ChakraProvider>,
+ <StrictMode>
+ <ChakraProvider
+ toastOptions={{ defaultOptions: { position: "top" } }}
+ theme={theme}
+ >
+ <StatsigProviderWrapper>
+ <PostHogProviderWrapper>
+ <SentryErrorBoundary>
+ <App />
+ <Toaster />
+ <SonnerToaster />
+ </SentryErrorBoundary>
+ </PostHogProviderWrapper>
+ </StatsigProviderWrapper>
+ </ChakraProvider>
+ </StrictMode>,
);
diff --git a/src/pages/SalePage/SalePage.tsx b/src/pages/SalePage/SalePage.tsx
index c226b7b2b..fafacca41 100644
--- a/src/pages/SalePage/SalePage.tsx
+++ b/src/pages/SalePage/SalePage.tsx
@@ -20,7 +20,7 @@ import { QuickPicks } from "../../components/SalesScreenComponents/SalesButtonGr
import { SalesScreenItemsTable } from "../../components/SalesScreenComponents/SalesScreenItemsTable";
import SaleScreenNavBar from "../../components/navbar/SaleScreenNavBar";
-import { usePGlite } from "@electric-sql/pglite-react";
+import { usePGlite } from "components/pg-lite/PGliteProvider";
import { SalePageSyncOverlay } from "components/SalePageSyncOverlay";
import { VariablePriceModal } from "components/SalesScreenComponents/Modals/VariablePriceModal";
import { useSalePageConfig } from "components/SalesScreenComponents/SalePageConfigProvider";
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment