feat: add zero

This commit is contained in:
Max Koon
2025-10-13 21:10:46 -04:00
parent b4d13e6a9f
commit 92d297e2c9
36 changed files with 3318 additions and 455 deletions

11
.env.example Normal file
View File

@@ -0,0 +1,11 @@
DATABASE_URL=postgresql://postgres@localhost:5432/money
OAUTH_CLIENT_ID=
OAUTH_CLIENT_SECRET=
OAUTH_DISCOVERY_URL=https://www.example.com/.well-known/openid-configuration
ZERO_UPSTREAM_DB=postgresql://postgres@localhost:5432/money
ZERO_REPLICA_FILE="/tmp/sync-replica.db"
ZERO_GET_QUERIES_URL="http://localhost:3000/api/zero/get-queries"

17
api/package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "@money/api",
"type": "module",
"private": true,
"scripts": {
"dev": "tsx watch src/index.ts"
},
"dependencies": {
"@hono/node-server": "^1.19.5",
"@money/shared": "link:../shared",
"better-auth": "^1.3.27",
"hono": "^4.9.12"
},
"devDependencies": {
"@types/node": "^24.7.2"
}
}

View File

@@ -7,6 +7,7 @@ export const auth = betterAuth({
database: new Pool({ database: new Pool({
connectionString: process.env.DATABASE_URL, connectionString: process.env.DATABASE_URL,
}), }),
trustedOrigins: ["money://", "http://localhost:8081"],
plugins: [ plugins: [
expo(), expo(),
genericOAuth({ genericOAuth({

5
api/src/hono.ts Normal file
View File

@@ -0,0 +1,5 @@
import type { AuthData } from "@money/shared/auth";
import { Hono } from "hono";
export const getHono = () =>
new Hono<{ Variables: { auth: AuthData | null } }>();

55
api/src/index.ts Normal file
View File

@@ -0,0 +1,55 @@
import { serve } from "@hono/node-server";
import { authDataSchema } from "@money/shared/auth";
import { cors } from "hono/cors";
import { auth } from "./auth";
import { getHono } from "./hono";
import { zero } from "./zero";
const app = getHono();
app.use(
"/api/*",
cors({
origin: (origin) => origin ?? "",
allowMethods: ["POST", "GET", "OPTIONS"],
allowHeaders: ["Content-Type", "Authorization"],
credentials: true,
}),
);
app.on(["GET", "POST"], "/api/auth/*", (c) => auth.handler(c.req.raw));
app.use("*", async (c, next) => {
const authHeader = c.req.raw.headers.get("Authorization");
const cookie = authHeader?.split("Bearer ")[1];
const newHeaders = new Headers(c.req.raw.headers);
if (cookie) {
newHeaders.set("Cookie", cookie);
}
const session = await auth.api.getSession({ headers: newHeaders });
if (!session) {
c.set("auth", null);
return next();
}
c.set("auth", authDataSchema.parse(session));
return next();
});
app.route("/api/zero", zero);
app.get("/api", (c) => c.text("OK"));
app.get("/", (c) => c.text("OK"));
serve(
{
fetch: app.fetch,
port: 3000,
},
(info) => {
console.log(`Server is running on ${info.address}:${info.port}`);
},
);

96
api/src/zero.ts Normal file
View File

@@ -0,0 +1,96 @@
import {
type ReadonlyJSONValue,
type ServerTransaction,
withValidation,
} from "@rocicorp/zero";
import {
handleGetQueriesRequest,
PushProcessor,
ZQLDatabase,
} from "@rocicorp/zero/server";
import {
// createMutators as createMutatorsShared,
// isLoggedIn,
// type Mutators,
queries,
schema,
type Schema,
} from "@money/shared";
import type { AuthData } from "@money/shared/auth";
// import { auditLogs } from "@zslack/shared/db";
// import {
// NodePgConnection,
// type NodePgZeroTransaction,
// } from "drizzle-zero/node-postgres";
import crypto from "node:crypto";
// import { db } from "./db";
import { getHono } from "./hono";
// type ServerTx = ServerTransaction<Schema, NodePgZeroTransaction<typeof db>>;
// const processor = new PushProcessor(
// new ZQLDatabase(new NodePgConnection(db), schema),
// );
//
// const createMutators = (authData: AuthData | null) => {
// const mutators = createMutatorsShared(authData);
//
// return {
// ...mutators,
// message: {
// ...mutators.message,
// async sendMessage(tx: ServerTx, params) {
// isLoggedIn(authData);
//
// await mutators.message.sendMessage(tx, params);
//
// // we can use the db tx to insert server-only data, like audit logs
// await tx.dbTransaction.wrappedTransaction.insert(auditLogs).values({
// id: crypto.randomUUID(),
// userId: authData.user.id,
// action: "sendMessage",
// });
// },
// },
// } as const satisfies Mutators;
// };
const zero = getHono()
// .post("/mutate", async (c) => {
// // get the auth data from betterauth
// const authData = c.get("auth");
//
// const result = await processor.process(createMutators(authData), c.req.raw);
//
// return c.json(result);
// })
.post("/get-queries", async (c) => {
// get the auth data from betterauth
const authData = c.get("auth");
const result = await handleGetQueriesRequest(
(name, args) => ({ query: getQuery(authData, name, args) }),
schema,
c.req.raw,
);
return c.json(result);
});
const validatedQueries = Object.fromEntries(
Object.values(queries).map((q) => [q.queryName, withValidation(q)]),
);
function getQuery(
authData: AuthData | null,
name: string,
args: readonly ReadonlyJSONValue[],
) {
if (name in validatedQueries) {
const q = validatedQueries[name];
return q(authData, ...args);
}
throw new Error(`Unknown query: ${name}`);
}
export { zero };

14
api/tsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"outDir": "./dist"
},
"exclude": ["node_modules"]
}

View File

@@ -1,21 +1,20 @@
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
import { Stack } from 'expo-router'; import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import 'react-native-reanimated'; import 'react-native-reanimated';
import { useColorScheme } from '@/hooks/use-color-scheme';
import { authClient } from '@/lib/auth-client'; import { authClient } from '@/lib/auth-client';
import { ZeroProvider } from '@rocicorp/zero/react';
import { zero } from '@/lib/zero';
export const unstable_settings = { export const unstable_settings = {
anchor: '(tabs)', anchor: '(tabs)',
}; };
export default function RootLayout() { export default function RootLayout() {
const colorScheme = useColorScheme();
const { data, isPending } = authClient.useSession(); const { data, isPending } = authClient.useSession();
return ( return (
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}> <ZeroProvider zero={zero}>
<Stack> <Stack>
<Stack.Protected guard={!isPending && !!data}> <Stack.Protected guard={!isPending && !!data}>
<Stack.Screen name="index" /> <Stack.Screen name="index" />
@@ -24,7 +23,6 @@ export default function RootLayout() {
<Stack.Screen name="auth" /> <Stack.Screen name="auth" />
</Stack.Protected> </Stack.Protected>
</Stack> </Stack>
<StatusBar style="auto" /> </ZeroProvider>
</ThemeProvider>
); );
} }

View File

@@ -1,4 +0,0 @@
import { auth } from "@/lib/auth";
const handler = auth.handler;
export { handler as GET, handler as POST };

View File

@@ -5,6 +5,7 @@ export default function Auth() {
const onLogin = () => { const onLogin = () => {
authClient.signIn.oauth2({ authClient.signIn.oauth2({
providerId: "koon-family", providerId: "koon-family",
callbackURL: "http://localhost:8081"
}); });
}; };

View File

@@ -1,18 +1,21 @@
import { ThemedText } from '@/components/themed-text';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { authClient } from '@/lib/auth-client'; import { authClient } from '@/lib/auth-client';
import { Button } from 'react-native'; import { Button, Text } from 'react-native';
import { useQuery, useZero } from "@rocicorp/zero/react";
import { queries } from '@money/shared';
export default function HomeScreen() { export default function HomeScreen() {
const { data } = authClient.useSession(); const { data } = authClient.useSession();
const onLogout = () => { const onLogout = () => {
authClient.signOut(); authClient.signOut();
} }
const [transactions] = useQuery(queries.allTransactions());
return ( return (
<SafeAreaView> <SafeAreaView>
<ThemedText>Hello {data?.user.name}</ThemedText> <Text>Hello {data?.user.name}</Text>
<Button onPress={onLogout} title="Logout" /> <Button onPress={onLogout} title="Logout" />
<Text>Transactions: {JSON.stringify(transactions, null, 4)}</Text>
</SafeAreaView> </SafeAreaView>
); );
} }

View File

@@ -1,25 +0,0 @@
import { Href, Link } from 'expo-router';
import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser';
import { type ComponentProps } from 'react';
type Props = Omit<ComponentProps<typeof Link>, 'href'> & { href: Href & string };
export function ExternalLink({ href, ...rest }: Props) {
return (
<Link
target="_blank"
{...rest}
href={href}
onPress={async (event) => {
if (process.env.EXPO_OS !== 'web') {
// Prevent the default behavior of linking to the default browser on native.
event.preventDefault();
// Open the link in an in-app browser.
await openBrowserAsync(href, {
presentationStyle: WebBrowserPresentationStyle.AUTOMATIC,
});
}
}}
/>
);
}

View File

@@ -1,18 +0,0 @@
import { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs';
import { PlatformPressable } from '@react-navigation/elements';
import * as Haptics from 'expo-haptics';
export function HapticTab(props: BottomTabBarButtonProps) {
return (
<PlatformPressable
{...props}
onPressIn={(ev) => {
if (process.env.EXPO_OS === 'ios') {
// Add a soft haptic feedback when pressing down on the tabs.
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
props.onPressIn?.(ev);
}}
/>
);
}

View File

@@ -1,19 +0,0 @@
import Animated from 'react-native-reanimated';
export function HelloWave() {
return (
<Animated.Text
style={{
fontSize: 28,
lineHeight: 32,
marginTop: -6,
animationName: {
'50%': { transform: [{ rotate: '25deg' }] },
},
animationIterationCount: 4,
animationDuration: '300ms',
}}>
👋
</Animated.Text>
);
}

View File

@@ -1,79 +0,0 @@
import type { PropsWithChildren, ReactElement } from 'react';
import { StyleSheet } from 'react-native';
import Animated, {
interpolate,
useAnimatedRef,
useAnimatedStyle,
useScrollOffset,
} from 'react-native-reanimated';
import { ThemedView } from '@/components/themed-view';
import { useColorScheme } from '@/hooks/use-color-scheme';
import { useThemeColor } from '@/hooks/use-theme-color';
const HEADER_HEIGHT = 250;
type Props = PropsWithChildren<{
headerImage: ReactElement;
headerBackgroundColor: { dark: string; light: string };
}>;
export default function ParallaxScrollView({
children,
headerImage,
headerBackgroundColor,
}: Props) {
const backgroundColor = useThemeColor({}, 'background');
const colorScheme = useColorScheme() ?? 'light';
const scrollRef = useAnimatedRef<Animated.ScrollView>();
const scrollOffset = useScrollOffset(scrollRef);
const headerAnimatedStyle = useAnimatedStyle(() => {
return {
transform: [
{
translateY: interpolate(
scrollOffset.value,
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
),
},
{
scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]),
},
],
};
});
return (
<Animated.ScrollView
ref={scrollRef}
style={{ backgroundColor, flex: 1 }}
scrollEventThrottle={16}>
<Animated.View
style={[
styles.header,
{ backgroundColor: headerBackgroundColor[colorScheme] },
headerAnimatedStyle,
]}>
{headerImage}
</Animated.View>
<ThemedView style={styles.content}>{children}</ThemedView>
</Animated.ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
height: HEADER_HEIGHT,
overflow: 'hidden',
},
content: {
flex: 1,
padding: 32,
gap: 16,
overflow: 'hidden',
},
});

View File

@@ -1,60 +0,0 @@
import { StyleSheet, Text, type TextProps } from 'react-native';
import { useThemeColor } from '@/hooks/use-theme-color';
export type ThemedTextProps = TextProps & {
lightColor?: string;
darkColor?: string;
type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link';
};
export function ThemedText({
style,
lightColor,
darkColor,
type = 'default',
...rest
}: ThemedTextProps) {
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
return (
<Text
style={[
{ color },
type === 'default' ? styles.default : undefined,
type === 'title' ? styles.title : undefined,
type === 'defaultSemiBold' ? styles.defaultSemiBold : undefined,
type === 'subtitle' ? styles.subtitle : undefined,
type === 'link' ? styles.link : undefined,
style,
]}
{...rest}
/>
);
}
const styles = StyleSheet.create({
default: {
fontSize: 16,
lineHeight: 24,
},
defaultSemiBold: {
fontSize: 16,
lineHeight: 24,
fontWeight: '600',
},
title: {
fontSize: 32,
fontWeight: 'bold',
lineHeight: 32,
},
subtitle: {
fontSize: 20,
fontWeight: 'bold',
},
link: {
lineHeight: 30,
fontSize: 16,
color: '#0a7ea4',
},
});

View File

@@ -1,14 +0,0 @@
import { View, type ViewProps } from 'react-native';
import { useThemeColor } from '@/hooks/use-theme-color';
export type ThemedViewProps = ViewProps & {
lightColor?: string;
darkColor?: string;
};
export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
return <View style={[{ backgroundColor }, style]} {...otherProps} />;
}

View File

@@ -1,45 +0,0 @@
import { PropsWithChildren, useState } from 'react';
import { StyleSheet, TouchableOpacity } from 'react-native';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
import { IconSymbol } from '@/components/ui/icon-symbol';
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
const [isOpen, setIsOpen] = useState(false);
const theme = useColorScheme() ?? 'light';
return (
<ThemedView>
<TouchableOpacity
style={styles.heading}
onPress={() => setIsOpen((value) => !value)}
activeOpacity={0.8}>
<IconSymbol
name="chevron.right"
size={18}
weight="medium"
color={theme === 'light' ? Colors.light.icon : Colors.dark.icon}
style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }}
/>
<ThemedText type="defaultSemiBold">{title}</ThemedText>
</TouchableOpacity>
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
</ThemedView>
);
}
const styles = StyleSheet.create({
heading: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
content: {
marginTop: 6,
marginLeft: 24,
},
});

View File

@@ -1,32 +0,0 @@
import { SymbolView, SymbolViewProps, SymbolWeight } from 'expo-symbols';
import { StyleProp, ViewStyle } from 'react-native';
export function IconSymbol({
name,
size = 24,
color,
style,
weight = 'regular',
}: {
name: SymbolViewProps['name'];
size?: number;
color: string;
style?: StyleProp<ViewStyle>;
weight?: SymbolWeight;
}) {
return (
<SymbolView
weight={weight}
tintColor={color}
resizeMode="scaleAspectFit"
name={name}
style={[
{
width: size,
height: size,
},
style,
]}
/>
);
}

View File

@@ -1,41 +0,0 @@
// Fallback for using MaterialIcons on Android and web.
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { SymbolWeight, SymbolViewProps } from 'expo-symbols';
import { ComponentProps } from 'react';
import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native';
type IconMapping = Record<SymbolViewProps['name'], ComponentProps<typeof MaterialIcons>['name']>;
type IconSymbolName = keyof typeof MAPPING;
/**
* Add your SF Symbols to Material Icons mappings here.
* - see Material Icons in the [Icons Directory](https://icons.expo.fyi).
* - see SF Symbols in the [SF Symbols](https://developer.apple.com/sf-symbols/) app.
*/
const MAPPING = {
'house.fill': 'home',
'paperplane.fill': 'send',
'chevron.left.forwardslash.chevron.right': 'code',
'chevron.right': 'chevron-right',
} as IconMapping;
/**
* An icon component that uses native SF Symbols on iOS, and Material Icons on Android and web.
* This ensures a consistent look across platforms, and optimal resource usage.
* Icon `name`s are based on SF Symbols and require manual mapping to Material Icons.
*/
export function IconSymbol({
name,
size = 24,
color,
style,
}: {
name: IconSymbolName;
size?: number;
color: string | OpaqueColorValue;
style?: StyleProp<TextStyle>;
weight?: SymbolWeight;
}) {
return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />;
}

View File

@@ -1,53 +0,0 @@
/**
* Below are the colors that are used in the app. The colors are defined in the light and dark mode.
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
*/
import { Platform } from 'react-native';
const tintColorLight = '#0a7ea4';
const tintColorDark = '#fff';
export const Colors = {
light: {
text: '#11181C',
background: '#fff',
tint: tintColorLight,
icon: '#687076',
tabIconDefault: '#687076',
tabIconSelected: tintColorLight,
},
dark: {
text: '#ECEDEE',
background: '#151718',
tint: tintColorDark,
icon: '#9BA1A6',
tabIconDefault: '#9BA1A6',
tabIconSelected: tintColorDark,
},
};
export const Fonts = Platform.select({
ios: {
/** iOS `UIFontDescriptorSystemDesignDefault` */
sans: 'system-ui',
/** iOS `UIFontDescriptorSystemDesignSerif` */
serif: 'ui-serif',
/** iOS `UIFontDescriptorSystemDesignRounded` */
rounded: 'ui-rounded',
/** iOS `UIFontDescriptorSystemDesignMonospaced` */
mono: 'ui-monospace',
},
default: {
sans: 'normal',
serif: 'serif',
rounded: 'normal',
mono: 'monospace',
},
web: {
sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
serif: "Georgia, 'Times New Roman', serif",
rounded: "'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif",
mono: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
},
});

View File

@@ -1 +0,0 @@
export { useColorScheme } from 'react-native';

View File

@@ -1,21 +0,0 @@
import { useEffect, useState } from 'react';
import { useColorScheme as useRNColorScheme } from 'react-native';
/**
* To support static rendering, this value needs to be re-calculated on the client side for web
*/
export function useColorScheme() {
const [hasHydrated, setHasHydrated] = useState(false);
useEffect(() => {
setHasHydrated(true);
}, []);
const colorScheme = useRNColorScheme();
if (hasHydrated) {
return colorScheme;
}
return 'light';
}

View File

@@ -1,21 +0,0 @@
/**
* Learn more about light and dark modes:
* https://docs.expo.dev/guides/color-schemes/
*/
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
export function useThemeColor(
props: { light?: string; dark?: string },
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
) {
const theme = useColorScheme() ?? 'light';
const colorFromProps = props[theme];
if (colorFromProps) {
return colorFromProps;
} else {
return Colors[theme][colorName];
}
}

View File

@@ -4,7 +4,7 @@ import { expoClient } from "@better-auth/expo/client";
import * as SecureStore from "expo-secure-store"; import * as SecureStore from "expo-secure-store";
export const authClient = createAuthClient({ export const authClient = createAuthClient({
baseURL: "http://localhost:8081", baseURL: "http://localhost:3000",
plugins: [ plugins: [
expoClient({ expoClient({
scheme: "money", scheme: "money",

8
lib/zero.ts Normal file
View File

@@ -0,0 +1,8 @@
import {Zero} from '@rocicorp/zero';
import { schema } from "@money/shared";
export const zero = new Zero({
userID: 'anon',
server: 'http://localhost:4848',
schema,
});

View File

@@ -13,9 +13,11 @@
"dependencies": { "dependencies": {
"@better-auth/expo": "^1.3.27", "@better-auth/expo": "^1.3.27",
"@expo/vector-icons": "^15.0.2", "@expo/vector-icons": "^15.0.2",
"@money/shared": "link:shared",
"@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3", "@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8", "@react-navigation/native": "^7.1.8",
"@rocicorp/zero": "^0.23.2025090100",
"better-auth": "^1.3.27", "better-auth": "^1.3.27",
"expo": "~54.0.13", "expo": "~54.0.13",
"expo-constants": "~18.0.9", "expo-constants": "~18.0.9",
@@ -47,5 +49,14 @@
"eslint-config-expo": "~10.0.0", "eslint-config-expo": "~10.0.0",
"typescript": "~5.9.2" "typescript": "~5.9.2"
}, },
"private": true "private": true,
"pnpm": {
"onlyBuiltDependencies": [
"@rocicorp/zero-sqlite3"
],
"ignoredBuiltDependencies": [
"esbuild",
"protobufjs"
]
}
} }

2987
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -26,16 +26,26 @@ processes:
period_seconds: 1 period_seconds: 1
expo: expo:
command: "pnpm i && pnpm start" command: "pnpm start"
environment: environment:
- "DATABASE_URL=postgresql://postgres@localhost:5432/money" - "DATABASE_URL=postgresql://postgres@localhost:5432/money"
api:
command: "pnpm run dev"
working_dir: ./api
migrate: migrate:
command: | command: |
createdb -h localhost -p 5432 -U postgres money 2>/dev/null || true createdb -h localhost -p 5432 -U postgres money 2>/dev/null || true
psql -h localhost -p 5432 -U postgres -c "ALTER SYSTEM SET wal_level = 'logical';"
echo "Migration and seeding complete!" echo "Migration and seeding complete!"
depends_on: depends_on:
db: db:
condition: process_healthy condition: process_healthy
zero:
command: npx zero-cache-dev -p shared/src/schema.ts
depends_on:
db:
condition: process_healthy

10
shared/package.json Normal file
View File

@@ -0,0 +1,10 @@
{
"name": "@money/shared",
"version": "0.0.0",
"type": "module",
"private": true,
"exports": {
".": "./src/index.ts",
"./auth": "./src/auth.ts"
}
}

25
shared/src/auth.ts Normal file
View File

@@ -0,0 +1,25 @@
import { z } from "zod";
export const sessionSchema = z.object({
id: z.string(),
userId: z.string(),
expiresAt: z.date(),
});
export const userSchema = z.object({
id: z.string(),
email: z.string(),
emailVerified: z.boolean(),
name: z.string(),
createdAt: z.date(),
updatedAt: z.date(),
});
export const authDataSchema = z.object({
session: sessionSchema,
user: userSchema,
});
export type Session = z.infer<typeof sessionSchema>;
export type User = z.infer<typeof userSchema>;
export type AuthData = z.infer<typeof authDataSchema>;

2
shared/src/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from "./schema";
export * from "./queries";

9
shared/src/queries.ts Normal file
View File

@@ -0,0 +1,9 @@
import { syncedQuery } from "@rocicorp/zero";
import { z } from "zod";
import { builder } from "@money/shared";
export const queries = {
allTransactions: syncedQuery('allTransactions', z.tuple([]), () =>
builder.transaction.limit(10)
),
};

24
shared/src/schema.ts Normal file
View File

@@ -0,0 +1,24 @@
import { createSchema, table, string, number, createBuilder, definePermissions } from "@rocicorp/zero";
const transaction = table('transaction')
.columns({
id: string(),
user_id: string(),
name: string(),
amount: number(),
})
.primaryKey('id');
export const schema = createSchema({
tables: [transaction],
enableLegacyMutators: false,
enableLegacyQueries: false,
});
export const builder = createBuilder(schema);
export const permissions = definePermissions(schema, () => ({}));
export type Schema = typeof schema;

15
shared/tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"declaration": true,
"outDir": "./dist",
"strict": true,
"verbatimModuleSyntax": true,
"esModuleInterop": true,
"skipLibCheck": true,
"rootDir": "."
},
"include": ["src"]
}

View File

@@ -2,16 +2,10 @@
"extends": "expo/tsconfig.base", "extends": "expo/tsconfig.base",
"compilerOptions": { "compilerOptions": {
"strict": true, "strict": true,
"verbatimModuleSyntax": true,
"paths": { "paths": {
"@/*": [ "@/*": ["./*"]
"./*"
]
} }
}, },
"include": [ "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts"
]
} }