Created
October 1, 2025 22:09
-
-
Save jay-babu/0adef0c0e10a398e07b32883db796cde to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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