Empty List implemented

This commit is contained in:
KarimTamani
2025-07-04 21:45:19 +01:00
parent 9ecc2f9d56
commit e968dcdd96
15 changed files with 279 additions and 122 deletions
+46
View File
@@ -0,0 +1,46 @@
import { Image } from "@heroui/react";
import { useTheme } from "next-themes";
interface EmptyListProps {
title?: string;
description?: string;
}
const EmptyList: React.FC<EmptyListProps> = ({ title, description }) => {
return (
<div className="flex flex-col items-center gap-2 h-full pt-[86px]">
{
<Image
width={82}
src={"/public/stackrender.png"}
/>
}
{
title &&
<h1 className="text-font/90 font-bold text-lg">
{title}
</h1>
}
{
description &&
<p className="text-sm text-font/70">
{description}
</p>
}
</div>
)
}
export default EmptyList;
+6 -6
View File
@@ -21,7 +21,7 @@ const Menu: React.FC = ({ }) => {
const { canRedo, canUndo, undo, redo } = useDatabaseHistory();
const { deleteMultiTables } = useDatabaseOperations();
const { database } = useDatabase();
const { setShowController, showController, cardinalityStyle, setCardinalityStyle } = useDiagramOps();
const { openController, showController, cardinalityStyle, changeCardinalityStyle } = useDiagramOps();
const menu: MenuDropdownProps[] = useMemo(() => [
{
@@ -94,21 +94,21 @@ const Menu: React.FC = ({ }) => {
id: "menu.symbolic",
selected: cardinalityStyle == CardinalityStyle.SYMBOLIC,
title: t("menu.symbolic"), clickHandler: () => {
setCardinalityStyle(CardinalityStyle.SYMBOLIC)
changeCardinalityStyle(CardinalityStyle.SYMBOLIC)
}
},
{
id: "menu.numeric",
selected: cardinalityStyle == CardinalityStyle.NUMERIC,
title: t("menu.numeric"), clickHandler: () => {
setCardinalityStyle(CardinalityStyle.NUMERIC)
changeCardinalityStyle(CardinalityStyle.NUMERIC)
}
},
{
id: "menu.hidden",
selected: cardinalityStyle == CardinalityStyle.HIDDEN,
title: t("menu.hidden"), clickHandler: () => {
setCardinalityStyle(CardinalityStyle.HIDDEN)
changeCardinalityStyle(CardinalityStyle.HIDDEN)
}
},
@@ -117,7 +117,7 @@ const Menu: React.FC = ({ }) => {
{
id: "menu.controller_visibility",
title: showController ? t("menu.hide_controller") : t("menu.show_controller"), shortcut: "Ctnl + B", divide: true, clickHandler: () => {
setShowController(!showController)
openController(!showController)
}
},
@@ -151,7 +151,7 @@ const Menu: React.FC = ({ }) => {
{ id: "menu.join_discord", title: t("menu.join_discord") },
],
},
], [t, canRedo, canUndo, undo, redo, deleteMultiTables, database, showController, setShowController, cardinalityStyle, setCardinalityStyle, resolvedTheme]);
], [t, canRedo, canUndo, undo, redo, deleteMultiTables, database, showController, openController ,changeCardinalityStyle , cardinalityStyle, resolvedTheme]);
return <div className="gap-1 flex">
{
+5 -6
View File
@@ -16,8 +16,6 @@ const ConnectionStatus: React.FC = () => {
const clearProgressHandler: any = useRef(undefined);
const { t } = useTranslation();
const [lastUploadAt, setLastUploadAt] = useState<Date | undefined>(() => {
const last_upload_at = localStorage.getItem("last_upload_at");
if (last_upload_at)
@@ -47,6 +45,7 @@ const ConnectionStatus: React.FC = () => {
return () => l?.();
}, [powerSync]);
useEffect(() => {
if (syncStatus.downloadProgress?.downloadedOperations && syncStatus.downloadProgress?.totalOperations) {
@@ -93,7 +92,7 @@ const ConnectionStatus: React.FC = () => {
<div className="flex items-center ">
<Tooltip>
<TooltipTrigger asChild>
<div className="border-1 flex gap-2 p-2 px-3 rounded-lg text-xs relative overflow-hidden border-divider dark:border-font/10">
<div className="border-1 flex gap-2 p-2 px-3 rounded-sm text-xs relative overflow-hidden border-divider dark:border-font/10">
<span className={cn("flex gap-2 font-semibold", syncStatus.connected ? "text-success" : "text-danger")}>
{
syncStatus.connected ?
@@ -117,9 +116,9 @@ const ConnectionStatus: React.FC = () => {
<Progress
classNames={{
base: "max-w-md absolute bottom-[-2px] left-0 w-full ",
base: "max-w-md absolute bottom-[-2px] left-0 w-full rounded-sm",
track: " bg-transparent",
indicator: "bg-gradient-to-r from-secondary-200 to-primary ",
indicator: "bg-gradient-to-r from-secondary-100 to-primary ",
}}
radius="sm"
aria-label="upload progress"
@@ -130,7 +129,7 @@ const ConnectionStatus: React.FC = () => {
</>
:
<span className="text-font/70 truncate font-semibold" >
<span className="text-font/90 truncate font-semibold" >
{t("connection_status.saved")} {deltaUploadTime}
</span>
+7 -4
View File
@@ -43,6 +43,13 @@ export const en = {
select_table: "Select table",
select_field: "Select field",
empty_list : {
no_tables : "No Tables" ,
no_tables_description : "Create a Table to get started." ,
no_relationships : "No Relationships" ,
no_relationships_description : "Create relationships to connect two tables"
} ,
cardinality: {
name: "Cardinality",
@@ -99,12 +106,8 @@ export const en = {
index_setting: "Index Setting",
table_actions: "Table Actions",
actions: "Actions",
delete_index: "Delete Index",
index_name: "Index name",
create_relationship: "Create Relationship",
relationship_error: "To create a relationship, the primary key and foreign key must be of the same type.",
+9 -5
View File
@@ -55,7 +55,7 @@ const DatabasePage: React.FC = () => {
const { t } = useTranslation();
const { resolvedTheme } = useTheme();
// Extract database state and operations
const { database, getField, databases, isSwitchingDatabase, isLoading, isFetching } = useDatabase();
const { database, getField, databases, isSwitchingDatabase, isLoading, isFetching, isSyncing } = useDatabase();
const { updateTablePositions, deleteMultiTables, deleteMultiRelationships, createRelationship } = useDatabaseOperations();
@@ -64,7 +64,7 @@ const DatabasePage: React.FC = () => {
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
// Diagram-related state (e.g. connection in progress)
const { setIsConnectionInProgress, cardinalityStyle, setCardinalityStyle } = useDiagramOps();
const { setIsConnectionInProgress, cardinalityStyle } = useDiagramOps();
// Destructure tables and relationships from database
const { tables, relationships } = database || { tables: [], relationships: [] };
@@ -78,7 +78,7 @@ const DatabasePage: React.FC = () => {
useEffect(() => {
if (isLoading || isFetching || !(syncStatus.currentStatus as any).options.hasSynced)
if (isSyncing)
return;
// no database selected , open (open database) Modal
@@ -94,7 +94,7 @@ const DatabasePage: React.FC = () => {
closable: false
});
}
}, [database?.id, isLoading, isFetching, (syncStatus.currentStatus as any).options.hasSynced])
}, [database?.id, isSyncing])
useEffect(() => {
if (isSwitchingDatabase) {
@@ -254,11 +254,15 @@ const DatabasePage: React.FC = () => {
</Controls >
<MiniMap
nodeStrokeWidth={4}
className="dark:bg-content1 bg-background border-1 border-default-200 dark:border-divider rounded-md"
className="dark:bg-content1 bg-background border-1 border-default-200 dark:border-divider rounded-md "
maskStrokeColor={resolvedTheme == "dark" ? "#272c35" : "#e9edf1"}
maskColor={resolvedTheme == "dark" ? "#20252c99" : "#f2f4f733"}
maskStrokeWidth={1}
nodeClassName={"fill-default-300 dark:fill-font/30"}
style={{
width: 164,
height: 128
}}
/>
<Background className="dark:bg-background-100 " />
@@ -6,14 +6,16 @@ import { useTranslation } from "react-i18next";
import RelationshipAccordionHeader from "./relationship-accordion-item/relationship-accordion-header";
import RelationshipAccordionBody from "./relationship-accordion-item/relationship-accordion-body";
import CreateRelationshipForm from "../../modals/create-relationship-modal";
import { RelationshipInsertType, RelationshipType } from "@/lib/schemas/relationship-schema";
import { useDatabase, useDatabaseOperations } from "@/providers/database-provider/database-provider";
import { RelationshipType } from "@/lib/schemas/relationship-schema";
import { useDatabase } from "@/providers/database-provider/database-provider";
import { useDiagram } from "@/providers/diagram-provider/diagram-provider";
import { useModal } from "@/providers/modal-provider/modal-provider";
import { Modals } from "@/providers/modal-provider/modal-contxet";
import { getDefaultRelationshipName } from "@/utils/relationship";
import EmptyList from "@/components/empty-list/empty-list";
@@ -25,7 +27,7 @@ const RelationshipController: React.FC = ({ }) => {
const { open } = useModal();
const { database } = useDatabase();
const { database, isSyncing } = useDatabase();
const { relationships: allRelationships } = database || { relationships: [] };
const [relationships, setRelationships] = useState<RelationshipType[]>(allRelationships);
@@ -49,7 +51,7 @@ const RelationshipController: React.FC = ({ }) => {
const onOpen = useCallback(() => {
open(Modals.CREATE_RELATIONSHIP, {
onRlationshipCreated: (id: string) => setSelectedRelationship(new Set([id]) as any)
onRlationshipCreated: (id: string) => setSelectedRelationship(new Set([id]) as any)
})
}, [])
@@ -130,37 +132,48 @@ const RelationshipController: React.FC = ({ }) => {
>{t("db_controller.add_relationship")}
</Button>
</div>
<div className=" flex-1 overflow-auto">
{
allRelationships.length > 0 &&
<div className=" flex-1 overflow-auto">
<Accordion
hideIndicator
<Accordion
hideIndicator
isCompact
selectedKeys={selectedRelationship}
onSelectionChange={setSelectedRelationship as any}
>
{relationships.map((relationship: RelationshipType) => (
<AccordionItem
key={relationship.id}
id={relationship.id}
aria-label={relationship.id}
classNames={{
trigger: "w-full hover:bg-default transition-all duration-200 h-12 dark:hover:bg-background",
isCompact
selectedKeys={selectedRelationship}
onSelectionChange={setSelectedRelationship as any}
>
{relationships.map((relationship: RelationshipType) => (
<AccordionItem
key={relationship.id}
id={relationship.id}
aria-label={relationship.id}
classNames={{
trigger: "w-full hover:bg-default transition-all duration-200 h-12 dark:hover:bg-background",
base: "rounded-md p-0 overflow-hidden",
}}
subtitle={
<RelationshipAccordionHeader
isOpen={selectedRelationshipId == relationship.id}
relationship={relationship} />
}
>
<RelationshipAccordionBody relationship={relationship} />
</AccordionItem>
))}
</Accordion>
</div>
base: "rounded-md p-0 overflow-hidden",
}}
subtitle={
<RelationshipAccordionHeader
isOpen={selectedRelationshipId == relationship.id}
relationship={relationship} />
}
>
<RelationshipAccordionBody relationship={relationship} />
</AccordionItem>
))}
</Accordion>
</div>
}
{
!isSyncing && allRelationships.length == 0 &&
<EmptyList
title={t("db_controller.empty_list.no_relationships")}
description={t("db_controller.empty_list.no_relationships_description")}
/>
}
</div>
)
@@ -11,6 +11,7 @@ import { v4 } from "uuid";
import { useDiagram } from "@/providers/diagram-provider/diagram-provider";
import { useReactFlow } from "@xyflow/react";
import SqlPreview from "../sql-preview";
import EmptyList from "@/components/empty-list/empty-list";
const PADDING_X = 40;
@@ -19,19 +20,19 @@ const PADDING_Y = 80;
const TablesController: React.FC = ({ }) => {
const { database } = useDatabase();
const { database , isSyncing } = useDatabase();
const { createTable, getInteger } = useDatabaseOperations();
const { getViewport } = useReactFlow();
const { tables: allTables } = database || { tables: [] };
const [tables, setTables] = useState<TableType[]>(allTables);
const { t } = useTranslation();
const [selectedTable, setSelectedTable] = useState(new Set([]));
const [showSqlPreview, setShowSqlPreview] = useState<boolean>(false);
const { focusedTableId } = useDiagram();
const nameRef: Ref<HTMLInputElement> = useRef<HTMLInputElement>(null);
const accordionRef = useRef<HTMLDivElement>(null);
useEffect(() => searchTables(), [allTables]);
const addNewTable = useCallback(async () => {
@@ -65,7 +66,7 @@ const TablesController: React.FC = ({ }) => {
useEffect(() => {
if (focusedTableId) {
setSelectedTable(new Set([focusedTableId]) as any);
setShowSqlPreview(false) ;
setShowSqlPreview(false);
const accordionItem = document.getElementById(focusedTableId)
if (accordionItem)
accordionItem?.scrollIntoView({
@@ -145,7 +146,7 @@ const TablesController: React.FC = ({ }) => {
</Button>
</div>
{
!showSqlPreview &&
allTables.length > 0 && !showSqlPreview &&
<div className=" flex-1 overflow-auto">
<Accordion
hideIndicator
@@ -177,14 +178,20 @@ const TablesController: React.FC = ({ }) => {
</div>
}
{
showSqlPreview &&
allTables.length > 0 && showSqlPreview &&
<div className=" flex-1 overflow-auto ">
<SqlPreview
tableFilterIds={tableFilterIds}
/>
</div>
}
{
!isSyncing && allTables.length == 0 &&
<EmptyList
title={t("db_controller.empty_list.no_tables")}
description={t("db_controller.empty_list.no_tables_description")}
/>
}
</div>
)
}
@@ -12,8 +12,8 @@ interface Props { children: React.ReactNode };
const DatabaseHistoryProvider: React.FC<Props> = ({ children }) => {
const udpateDbFlag = useRef(false);
const { database , isLoading , isFetching } = useDatabase() ;
const { executeDbDiffOps } = useDatabaseOperations();
const { database, isLoading, isFetching } = useDatabase();
const { executeDbDiffOps } = useDatabaseOperations();
const [isProcessing, setIsProcessing] = useState<boolean>(false);
const [datatbaseState, { set, undo: undoChanges, redo: redoChanges, canUndo, canRedo, reset }] = useUndo<DatabaseType>(database as DatabaseType);
@@ -26,12 +26,12 @@ const DatabaseHistoryProvider: React.FC<Props> = ({ children }) => {
useEffect(() => {
if (database && datatbaseState.present && !isLoading && !isFetching) {
udpateDbFlag.current = false;
const presentHash: string | undefined = hash(datatbaseState.present, { algorithm: 'sha1' });
const databaseHash: string = hash(database, { algorithm: 'sha1' });
if (presentHash != databaseHash) {
if (presentHash != databaseHash) {
set(database);
}
}
@@ -39,18 +39,18 @@ const DatabaseHistoryProvider: React.FC<Props> = ({ children }) => {
const undo = useCallback(() => {
if (!isProcessing) {
if (!isProcessing && !isLoading && !isFetching) {
udpateDbFlag.current = true;
undoChanges();
}
}, [undoChanges, udpateDbFlag, isProcessing]);
}, [undoChanges, udpateDbFlag, isProcessing , isLoading , isFetching]);
const redo = useCallback(() => {
if (!isProcessing) {
if (!isProcessing && !isLoading && !isFetching) {
udpateDbFlag.current = true;
redoChanges();
}
}, [redoChanges, udpateDbFlag, isProcessing]);
}, [redoChanges, udpateDbFlag, isProcessing, isLoading , isFetching]);
useEffect(() => {
@@ -66,8 +66,8 @@ const DatabaseHistoryProvider: React.FC<Props> = ({ children }) => {
const normalizedPresent = normalizeDatabase(datatbaseState.present);
const differences = compare(normalizedDatabase, normalizedPresent);
//console.log ( differences )
if (differences && differences.length > 0) {
setIsProcessing(true);
@@ -75,13 +75,13 @@ const DatabaseHistoryProvider: React.FC<Props> = ({ children }) => {
(async () => {
try {
// console.log ( operations) ;
console.log(operations);
await executeDbDiffOps(operations)
setIsProcessing(false);
}
catch (error) {
console.log ( error ) ;
/// set(database);
console.log(error);
setIsProcessing(false);
}
})()
@@ -20,6 +20,7 @@ interface DatabaseDataContextType {
databases: DatabaseType[],
isLoading: boolean,
isFetching : boolean ,
isSyncing : boolean ,
isSwitchingDatabase: boolean,
getField: (tableId: string, id: string) => FieldType | undefined,
@@ -2,7 +2,7 @@ import { DatabaseDataContext, DatabaseOperationsContext } from "./database-conte
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
import { db } from "../sync-provider/sync-provider";
import { TableInsertType, tables, TableType } from "@/lib/schemas/table-schema";
import { useQuery } from "@powersync/react";
import { usePowerSync, useQuery } from "@powersync/react";
import { toCompilableQuery } from "@powersync/drizzle-driver";
import { asc, count, desc, eq, inArray, or } from "drizzle-orm";
import { QueryResult } from "@powersync/web";
@@ -23,7 +23,7 @@ import { deleteFieldsWithCascade, deleteTablesWithCascade } from "@/utils/cascad
interface Props { children: React.ReactNode }
const DatabaseProvider: React.FC<Props> = ({ children }) => {
const syncStatus = usePowerSync();
const [currentDatabaseId, setCurrentDatabaseId] = useState<string | undefined>(localStorage.getItem("database_id") as string | undefined);
// Fetch all databases
const { data: databases, isLoading: loadingDatabases, isFetching: fetchingDatabases } = useQuery(toCompilableQuery(
@@ -101,7 +101,7 @@ const DatabaseProvider: React.FC<Props> = ({ children }) => {
const isLoading: boolean = loadingDataTypes || loadingDatabases || loadingCurrentDatabase;
const isFetching: boolean = fetchingDatabase || fetchingDatatypes || fetchingDatabases;
const isSyncing : boolean = isLoading ||isFetching || !(syncStatus.currentStatus as any).options.hasSynced ;
const isSwitchingDatabase: boolean = useMemo(() => {
return fetchingDatabase && currentDatabaseId != (database as any)?.id
@@ -136,7 +136,6 @@ const DatabaseProvider: React.FC<Props> = ({ children }) => {
}).where(eq(databaseModel.id, databaseId))
}, []);
// CRUD operations for Tables
const createTable = useCallback(async (table: TableInsertType): Promise<void> => {
if (currentDatabaseId) {
@@ -153,8 +152,6 @@ const DatabaseProvider: React.FC<Props> = ({ children }) => {
);
}
});
} else {
throw Error("No Database selected");
}
@@ -302,8 +299,7 @@ const DatabaseProvider: React.FC<Props> = ({ children }) => {
name: operation.chnages.name
}).where(eq(databaseModel.id, currentDatabaseId)));
}
else if (operation.type == "UPDATE_NUM_TABLES") {
console.log("update number of tables with ", operation.value);
else if (operation.type == "UPDATE_NUM_TABLES") {
if (currentDatabaseId)
operations.push(
tx.update(databaseModel).set({
@@ -394,12 +390,10 @@ const DatabaseProvider: React.FC<Props> = ({ children }) => {
}
}
}
return await Promise.all(operations);
})
} catch (error) {
console.log(error);
throw error
}
}, [db, currentDatabaseId]);
@@ -520,6 +514,7 @@ const DatabaseProvider: React.FC<Props> = ({ children }) => {
isSwitchingDatabase,
isFetching,
getField,
isSyncing
}}>
<DatabaseOperationsContext.Provider value={databaseOpsValue}>
<DatabaseHistoryProvider>
@@ -1,5 +1,5 @@
import { CardinalityStyle } from "@/lib/database";
import { createContext, Dispatch } from "react";
import { createContext, Dispatch } from "react";
interface DiagramDataContextType {
@@ -9,14 +9,13 @@ interface DiagramDataContextType {
interface DiagramOpsContextType {
focusOnTable: (id: string, transition?: boolean) => void,
focusOnRelationship: (id: string, transition?: boolean , withNavigate? : boolean) => void,
focusOnRelationship: (id: string, transition?: boolean, withNavigate?: boolean) => void,
setIsConnectionInProgress: Dispatch<boolean>,
isConnectionInProgress: boolean ,
showController : boolean ,
setShowController : ( value : boolean ) => void ,
cardinalityStyle : CardinalityStyle ,
setCardinalityStyle : (style : CardinalityStyle) => void
isConnectionInProgress: boolean,
showController: boolean,
openController: (value: boolean) => void,
cardinalityStyle: CardinalityStyle,
changeCardinalityStyle: (style: CardinalityStyle) => void
}
@@ -7,6 +7,8 @@ import { DiagramDataContext, DiagramOpsContext } from "./diagram-context";
import { CardinalityStyle } from "@/lib/database";
const CONTROLLER_VISIBILITY_KEY = "is_controller_open";
const CARDINALITY_STYLE_KEY = "cardinality_style";
interface Props { children: React.ReactNode }
@@ -17,8 +19,20 @@ const DiagramProvider: React.FC<Props> = ({ children }) => {
const [focusedTableId, setFocusedTableId] = useState<string | undefined>(undefined)
const [focusedRelationshipId, setFocusedRelationshipId] = useState<string | undefined>(undefined)
const [isConnectionInProgress, setIsConnectionInProgress] = useState<boolean>(false);
const [showController, setShowController] = useState<boolean>(true);
const [cardinalityStyle, setCardinalityStyle] = useState<CardinalityStyle>(CardinalityStyle.SYMBOLIC);
const [showController, setShowController] = useState<boolean>(() => {
const value: string | null = localStorage.getItem(CONTROLLER_VISIBILITY_KEY) ;
if (value)
return JSON.parse(value);
return true;
});
const [cardinalityStyle, setCardinalityStyle] = useState<CardinalityStyle>(() => {
const value: string | null = localStorage.getItem(CARDINALITY_STYLE_KEY);
if (value)
return value as CardinalityStyle;
return CardinalityStyle.SYMBOLIC;
});
const focusOnTable = useCallback((id: string, transition: boolean = false) => {
navigate("/database/tables");
@@ -26,8 +40,8 @@ const DiagramProvider: React.FC<Props> = ({ children }) => {
setNodes((nodes) =>
nodes.map((node) => {
const selected: boolean = node.id === id;
if (selected && transition) {
fitView({
duration: 500,
@@ -60,7 +74,7 @@ const DiagramProvider: React.FC<Props> = ({ children }) => {
setEdges((edges) =>
edges.map((edge) => {
const selected: boolean = edge.id === id;
if (selected && transition) {
@@ -87,6 +101,16 @@ const DiagramProvider: React.FC<Props> = ({ children }) => {
}, [setFocusedRelationshipId]);
const changeCardinalityStyle = useCallback((style : CardinalityStyle) => {
localStorage.setItem(CARDINALITY_STYLE_KEY , style) ;
setCardinalityStyle( style) ;
} , []) ;
const openController = useCallback((isOpen : boolean) => {
localStorage.setItem( CONTROLLER_VISIBILITY_KEY , JSON.stringify(isOpen) ) ;
setShowController(isOpen) ;
} , [])
const contextDatatValue = useMemo(() => ({
focusedTableId,
focusedRelationshipId,
@@ -99,9 +123,9 @@ const DiagramProvider: React.FC<Props> = ({ children }) => {
setIsConnectionInProgress,
isConnectionInProgress,
showController,
setShowController,
cardinalityStyle,
setCardinalityStyle
changeCardinalityStyle ,
openController
}), [focusOnTable, focusOnRelationship, setIsConnectionInProgress, isConnectionInProgress, showController, setShowController, cardinalityStyle,
setCardinalityStyle])
@@ -1,25 +1,25 @@
import { PowerSyncDatabase } from '@powersync/web';
import { PowerSyncContext } from "@powersync/react";
import { PowerSyncContext, usePowerSync } from "@powersync/react";
import { createContext, Suspense, useContext, useEffect, useState } from 'react';
import { AppSchema, drizzleSchema } from '@/lib/schemas/app-schema';
import { StackRenderConnector } from '@/utils/stackrender-connector';
import { CircularProgress } from '@heroui/react';
import { PowerSyncSQLiteDatabase, wrapPowerSyncWithDrizzle } from '@powersync/drizzle-driver';
export const powerSyncDb = new PowerSyncDatabase({
database: {
dbFilename: 'stackrender.sqlite',
},
schema: AppSchema,
});
export const db: PowerSyncSQLiteDatabase<typeof drizzleSchema> = wrapPowerSyncWithDrizzle(powerSyncDb, {
schema: drizzleSchema,
});
const ConnectorContext = createContext<StackRenderConnector | null>(null);
export const useConnector = () => useContext(ConnectorContext);
@@ -39,7 +39,11 @@ export const SyncProvider: React.FC<SyncProviderProps> = ({ children }) => {
powerSync.connect(connector);
};
setup();
}, [powerSync, connector])
}, [powerSync, connector]);
return (
<Suspense fallback={<CircularProgress />}>
<PowerSyncContext.Provider value={powerSync}>
+76 -17
View File
@@ -1,3 +1,15 @@
// ─────────────────────────────────────────────────────────────────────────────
// ❗ PowerSync Limitation: Manual Cascade Deletes Required
//
// PowerSync uses SQLite *views* instead of real tables, which disables support
// for standard SQLite features like FOREIGN KEY constraints and ON DELETE CASCADE.
//
// Because of this, any time we delete a parent record (like a table, field, or index),
// we must manually delete all dependent child records to maintain data integrity.
//
// These functions perform **manual cascade deletes** to avoid orphaned records.
// ─────────────────────────────────────────────────────────────────────────────
import { fields } from "@/lib/schemas/field-schema";
import { QueryResult } from "@powersync/web";
import { ExtractTablesWithRelations, inArray, or } from "drizzle-orm";
@@ -7,45 +19,92 @@ import { relationships } from "@/lib/schemas/relationship-schema";
import { field_indices } from "@/lib/schemas/field_index-schema";
import { indices } from "@/lib/schemas/index-schema";
export const deleteFieldsWithCascade = async (ids: string[], tx: SQLiteTransaction<'async', QueryResult, any, ExtractTablesWithRelations<any>>): Promise<QueryResult[]> => {
// ─────────────────────────────────────────────────────────────────────────────
// deleteFieldsWithCascade
//
// Manually deletes all related data when a field is removed.
// Removes associated relationships (source/target), field-index mappings, then the field itself.
// Used when deleting one or more fields by ID.
// ─────────────────────────────────────────────────────────────────────────────
export const deleteFieldsWithCascade = async (
ids: string[],
tx: SQLiteTransaction<'async', QueryResult, any, ExtractTablesWithRelations<any>>
): Promise<QueryResult[]> => {
return Promise.all([
tx.delete(relationships).where(or(inArray(relationships.sourceFieldId, ids), inArray(relationships.targetFieldId, ids))),
tx.delete(relationships).where(or(
inArray(relationships.sourceFieldId, ids),
inArray(relationships.targetFieldId, ids)
)),
tx.delete(field_indices).where(inArray(field_indices.fieldId, ids)),
tx.delete(fields).where(inArray(fields.id, ids)),
]);
}
};
export const deleteTablesWithCascade = async (ids: string[], tx: SQLiteTransaction<'async', QueryResult, any, ExtractTablesWithRelations<any>>): Promise<QueryResult[]> => {
// ─────────────────────────────────────────────────────────────────────────────
// deleteTablesWithCascade
//
// Handles deletion of entire tables, and manually cascades to:
// - all fields belonging to the table
// - all indices attached to the table
// - all relationships and field-index mappings tied to those fields/indices
//
// This ensures full cleanup when a table is removed in PowerSync context.
// ─────────────────────────────────────────────────────────────────────────────
export const deleteTablesWithCascade = async (
ids: string[],
tx: SQLiteTransaction<'async', QueryResult, any, ExtractTablesWithRelations<any>>
): Promise<QueryResult[]> => {
// Fetch all field IDs belonging to the tables
let fieldIds: { id: string }[] | string[] = await tx.select({
id: fields.id
}).from(fields).where(inArray(fields.tableId, ids));
// Fetch all index IDs belonging to the tables
let indicesIds: { id: string }[] | string[] = await tx.select({
id: indices.id
}).from(indices).where(inArray(indices.tableId, ids));
fieldIds = fieldIds.map((fieldId: { id: string }) => fieldId.id);
indicesIds = indicesIds.map((indexId: { id: string }) => indexId.id);
// Normalize into string arrays
fieldIds = fieldIds.map((fieldId) => fieldId.id);
indicesIds = indicesIds.map((indexId) => indexId.id);
return Promise.all([
tx.delete(relationships).where(or(inArray(relationships.sourceFieldId, fieldIds), inArray(relationships.targetFieldId, fieldIds))),
// Delete relationships where fields are involved
tx.delete(relationships).where(or(
inArray(relationships.sourceFieldId, fieldIds),
inArray(relationships.targetFieldId, fieldIds)
)),
// Delete field-index links for those fields
tx.delete(field_indices).where(inArray(field_indices.fieldId, fieldIds)),
// Delete all fields in the table
tx.delete(fields).where(inArray(fields.id, fieldIds)),
// Delete field-index links for indices
tx.delete(field_indices).where(inArray(field_indices.indexId, indicesIds)),
// Delete all indices belonging to the table
tx.delete(indices).where(inArray(indices.id, indicesIds)),
// Finally, delete the table(s) themselves
tx.delete(tables).where(inArray(tables.id, ids)),
]);
};
])
}
export const deleteIndicesWithCascade = async (ids: string[], tx: SQLiteTransaction<'async', QueryResult, any, ExtractTablesWithRelations<any>>): Promise<QueryResult[]> => {
// ─────────────────────────────────────────────────────────────────────────────
// deleteIndicesWithCascade
//
// When removing an index, this ensures that any field-index links are removed
// before the index itself is deleted. Prevents dangling associations.
// ─────────────────────────────────────────────────────────────────────────────
export const deleteIndicesWithCascade = async (
ids: string[],
tx: SQLiteTransaction<'async', QueryResult, any, ExtractTablesWithRelations<any>>
): Promise<QueryResult[]> => {
return Promise.all([
tx.delete(field_indices).where(inArray(field_indices.indexId, ids)),
tx.delete(indices).where(inArray(indices.id, ids))
])
}
]);
};
+4 -1
View File
@@ -279,7 +279,10 @@ export function normalizeDatabase(db: DatabaseType): any {
{
...table,
fields: Object.fromEntries(
table.fields.map(field => [field.id, field])
table.fields.map(field => [field.id, {
...field ,
type : undefined
}])
),
indices: Object.fromEntries(
table.indices.map(index => [index.id, {