@checkstack/theme-backend 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,47 @@
1
+ # @checkstack/theme-backend
2
+
3
+ ## 0.0.2
4
+
5
+ ### Patch Changes
6
+
7
+ - d20d274: Initial release of all @checkstack packages. Rebranded from Checkmate to Checkstack with new npm organization @checkstack and domain checkstack.dev.
8
+ - Updated dependencies [d20d274]
9
+ - @checkstack/auth-backend@0.0.2
10
+ - @checkstack/backend-api@0.0.2
11
+ - @checkstack/common@0.0.2
12
+ - @checkstack/theme-common@0.0.2
13
+
14
+ ## 0.0.4
15
+
16
+ ### Patch Changes
17
+
18
+ - Updated dependencies [b4eb432]
19
+ - Updated dependencies [a65e002]
20
+ - Updated dependencies [a65e002]
21
+ - @checkstack/backend-api@1.1.0
22
+ - @checkstack/common@0.2.0
23
+ - @checkstack/auth-backend@1.1.0
24
+ - @checkstack/theme-common@0.0.3
25
+
26
+ ## 0.0.3
27
+
28
+ ### Patch Changes
29
+
30
+ - @checkstack/auth-backend@1.0.1
31
+
32
+ ## 0.0.2
33
+
34
+ ### Patch Changes
35
+
36
+ - Updated dependencies [ffc28f6]
37
+ - Updated dependencies [71275dd]
38
+ - Updated dependencies [ae19ff6]
39
+ - Updated dependencies [32f2535]
40
+ - Updated dependencies [b55fae6]
41
+ - Updated dependencies [b354ab3]
42
+ - Updated dependencies [8e889b4]
43
+ - Updated dependencies [81f3f85]
44
+ - @checkstack/common@0.1.0
45
+ - @checkstack/backend-api@1.0.0
46
+ - @checkstack/auth-backend@1.0.0
47
+ - @checkstack/theme-common@0.0.2
@@ -0,0 +1,5 @@
1
+ CREATE TABLE "user_theme_preference" (
2
+ "user_id" text PRIMARY KEY NOT NULL,
3
+ "theme" text DEFAULT 'system' NOT NULL,
4
+ "updated_at" timestamp DEFAULT now() NOT NULL
5
+ );
@@ -0,0 +1,52 @@
1
+ {
2
+ "id": "b76ea33f-6e1b-477e-847e-009c75b0fd5a",
3
+ "prevId": "00000000-0000-0000-0000-000000000000",
4
+ "version": "7",
5
+ "dialect": "postgresql",
6
+ "tables": {
7
+ "public.user_theme_preference": {
8
+ "name": "user_theme_preference",
9
+ "schema": "",
10
+ "columns": {
11
+ "user_id": {
12
+ "name": "user_id",
13
+ "type": "text",
14
+ "primaryKey": true,
15
+ "notNull": true
16
+ },
17
+ "theme": {
18
+ "name": "theme",
19
+ "type": "text",
20
+ "primaryKey": false,
21
+ "notNull": true,
22
+ "default": "'system'"
23
+ },
24
+ "updated_at": {
25
+ "name": "updated_at",
26
+ "type": "timestamp",
27
+ "primaryKey": false,
28
+ "notNull": true,
29
+ "default": "now()"
30
+ }
31
+ },
32
+ "indexes": {},
33
+ "foreignKeys": {},
34
+ "compositePrimaryKeys": {},
35
+ "uniqueConstraints": {},
36
+ "policies": {},
37
+ "checkConstraints": {},
38
+ "isRLSEnabled": false
39
+ }
40
+ },
41
+ "enums": {},
42
+ "schemas": {},
43
+ "sequences": {},
44
+ "roles": {},
45
+ "policies": {},
46
+ "views": {},
47
+ "_meta": {
48
+ "columns": {},
49
+ "schemas": {},
50
+ "tables": {}
51
+ }
52
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "version": "7",
3
+ "dialect": "postgresql",
4
+ "entries": [
5
+ {
6
+ "idx": 0,
7
+ "version": "7",
8
+ "when": 1767319107725,
9
+ "tag": "0000_milky_paladin",
10
+ "breakpoints": true
11
+ }
12
+ ]
13
+ }
@@ -0,0 +1,8 @@
1
+ export default {
2
+ dialect: "postgresql",
3
+ schema: "./src/schema.ts",
4
+ out: "./drizzle",
5
+ dbCredentials: {
6
+ url: process.env.DATABASE_URL || "",
7
+ },
8
+ };
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@checkstack/theme-backend",
3
+ "version": "0.0.2",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "scripts": {
7
+ "typecheck": "tsc --noEmit",
8
+ "generate": "drizzle-kit generate",
9
+ "lint": "bun run lint:code",
10
+ "lint:code": "eslint . --max-warnings 0",
11
+ "test": "bun test"
12
+ },
13
+ "dependencies": {
14
+ "@checkstack/theme-common": "workspace:*",
15
+ "@checkstack/backend-api": "workspace:*",
16
+ "@checkstack/auth-backend": "workspace:*",
17
+ "drizzle-orm": "^0.45.1",
18
+ "zod": "^4.2.1",
19
+ "@checkstack/common": "workspace:*"
20
+ },
21
+ "devDependencies": {
22
+ "@checkstack/drizzle-helper": "workspace:*",
23
+ "@checkstack/scripts": "workspace:*",
24
+ "@checkstack/tsconfig": "workspace:*",
25
+ "@checkstack/test-utils-backend": "workspace:*",
26
+ "@orpc/server": "^1.13.2",
27
+ "@types/node": "^20.0.0",
28
+ "drizzle-kit": "^0.31.8",
29
+ "typescript": "^5.0.0"
30
+ }
31
+ }
package/src/index.ts ADDED
@@ -0,0 +1,50 @@
1
+ import {
2
+ createBackendPlugin,
3
+ coreServices,
4
+ } from "@checkstack/backend-api";
5
+ import { pluginMetadata, themeContract } from "@checkstack/theme-common";
6
+ import { eq } from "drizzle-orm";
7
+ import * as schema from "./schema";
8
+ import { createThemeRouter } from "./router";
9
+ import { authHooks } from "@checkstack/auth-backend";
10
+
11
+ export default createBackendPlugin({
12
+ metadata: pluginMetadata,
13
+
14
+ register(env) {
15
+ // Register initialization logic with schema
16
+ env.registerInit({
17
+ schema,
18
+ deps: {
19
+ database: coreServices.database,
20
+ rpc: coreServices.rpc,
21
+ logger: coreServices.logger,
22
+ },
23
+ init: async ({ database, rpc }) => {
24
+ // Create and register the theme router
25
+ const router = createThemeRouter(database);
26
+ rpc.registerRouter(router, themeContract);
27
+ },
28
+ afterPluginsReady: async ({ database, logger, onHook }) => {
29
+ const db = database;
30
+
31
+ // Subscribe to user deletion to clean up theme preferences
32
+ onHook(
33
+ authHooks.userDeleted,
34
+ async ({ userId }) => {
35
+ logger.debug(
36
+ `Cleaning up theme preference for deleted user: ${userId}`
37
+ );
38
+ await db
39
+ .delete(schema.userThemePreference)
40
+ .where(eq(schema.userThemePreference.userId, userId));
41
+ logger.debug(`Cleaned up theme preference for user: ${userId}`);
42
+ },
43
+ { mode: "work-queue", workerGroup: "user-cleanup" }
44
+ );
45
+
46
+ logger.debug("✅ Theme Backend afterPluginsReady complete.");
47
+ },
48
+ });
49
+ },
50
+ });
@@ -0,0 +1,127 @@
1
+ import { describe, it, expect, mock } from "bun:test";
2
+ import { createThemeRouter } from "./router";
3
+ import { createMockRpcContext } from "@checkstack/backend-api";
4
+ import { createMockDb } from "@checkstack/test-utils-backend";
5
+ import { call } from "@orpc/server";
6
+
7
+ describe("Theme Router", () => {
8
+ const mockUser = {
9
+ type: "user" as const,
10
+ id: "test-user-123",
11
+ permissions: [],
12
+ roles: [],
13
+ } as any;
14
+
15
+ const mockDb = createMockDb();
16
+ const router = createThemeRouter(mockDb as any);
17
+
18
+ describe("getTheme", () => {
19
+ it("returns user theme preference when it exists", async () => {
20
+ const context = createMockRpcContext({ user: mockUser });
21
+
22
+ // Mock the full query chain: select().from().where().limit()
23
+ const mockWhereChain = Object.assign(
24
+ Promise.resolve([{ theme: "dark" }]),
25
+ {
26
+ limit: mock(() => Promise.resolve([{ theme: "dark" }])),
27
+ }
28
+ );
29
+ const mockFromChain = Object.assign(
30
+ Promise.resolve([{ theme: "dark" }]),
31
+ {
32
+ where: mock(() => mockWhereChain),
33
+ }
34
+ );
35
+ mockDb.select.mockReturnValueOnce({
36
+ from: mock(() => mockFromChain),
37
+ } as any);
38
+
39
+ const result = await call(router.getTheme, undefined, { context });
40
+ expect(result.theme).toBe("dark");
41
+ });
42
+
43
+ it("returns 'system' as default when no preference exists", async () => {
44
+ const context = createMockRpcContext({ user: mockUser });
45
+
46
+ // Mock the full query chain returning empty array
47
+ const mockWhereChain = Object.assign(Promise.resolve([]), {
48
+ limit: mock(() => Promise.resolve([])),
49
+ });
50
+ const mockFromChain = Object.assign(Promise.resolve([]), {
51
+ where: mock(() => mockWhereChain),
52
+ });
53
+ mockDb.select.mockReturnValueOnce({
54
+ from: mock(() => mockFromChain),
55
+ } as any);
56
+
57
+ const result = await call(router.getTheme, undefined, { context });
58
+ expect(result.theme).toBe("system");
59
+ });
60
+
61
+ it("throws error when user is not authenticated", async () => {
62
+ const context = createMockRpcContext({ user: undefined });
63
+
64
+ await expect(
65
+ call(router.getTheme, undefined, { context })
66
+ ).rejects.toThrow("Authentication required");
67
+ });
68
+ });
69
+
70
+ describe("setTheme", () => {
71
+ it("saves theme preference successfully", async () => {
72
+ const context = createMockRpcContext({ user: mockUser });
73
+
74
+ const result = await call(
75
+ router.setTheme,
76
+ { theme: "dark" },
77
+ { context }
78
+ );
79
+
80
+ expect(result).toBeUndefined(); // setTheme returns void
81
+ expect(mockDb.insert).toHaveBeenCalled();
82
+ });
83
+
84
+ it("upserts theme preference with conflict handling", async () => {
85
+ const context = createMockRpcContext({ user: mockUser });
86
+
87
+ await call(router.setTheme, { theme: "light" }, { context });
88
+
89
+ // Verify insert was called (the mock from test-utils handles onConflictDoUpdate)
90
+ expect(mockDb.insert).toHaveBeenCalled();
91
+ });
92
+
93
+ it("accepts 'system' theme value", async () => {
94
+ const context = createMockRpcContext({ user: mockUser });
95
+
96
+ const result = await call(
97
+ router.setTheme,
98
+ { theme: "system" },
99
+ { context }
100
+ );
101
+
102
+ expect(result).toBeUndefined();
103
+ expect(mockDb.insert).toHaveBeenCalled();
104
+ });
105
+
106
+ it("throws error when user is not authenticated", async () => {
107
+ const context = createMockRpcContext({ user: undefined });
108
+
109
+ await expect(
110
+ call(router.setTheme, { theme: "dark" }, { context })
111
+ ).rejects.toThrow("Authentication required");
112
+ });
113
+
114
+ it("validates theme enum values", async () => {
115
+ const context = createMockRpcContext({ user: mockUser });
116
+
117
+ // This should be caught by Zod validation at the contract level
118
+ // but we can verify the router accepts valid values
119
+ const validThemes = ["light", "dark", "system"];
120
+
121
+ for (const theme of validThemes) {
122
+ await call(router.setTheme, { theme: theme as any }, { context });
123
+ expect(mockDb.insert).toHaveBeenCalled();
124
+ }
125
+ });
126
+ });
127
+ });
package/src/router.ts ADDED
@@ -0,0 +1,67 @@
1
+ import { implement } from "@orpc/server";
2
+ import {
3
+ autoAuthMiddleware,
4
+ type RpcContext,
5
+ type RealUser,
6
+ } from "@checkstack/backend-api";
7
+ import { themeContract } from "@checkstack/theme-common";
8
+ import * as schema from "./schema";
9
+ import { eq } from "drizzle-orm";
10
+ import type { NodePgDatabase } from "drizzle-orm/node-postgres";
11
+
12
+ /**
13
+ * Creates the theme router using contract-based implementation.
14
+ *
15
+ * Auth is automatically enforced via autoAuthMiddleware based on
16
+ * the contract's meta.userType (both endpoints are userType: "user").
17
+ */
18
+ export const createThemeRouter = (db: NodePgDatabase<typeof schema>) => {
19
+ // Create contract implementer with context type AND auto auth middleware
20
+ const os = implement(themeContract)
21
+ .$context<RpcContext>()
22
+ .use(autoAuthMiddleware);
23
+
24
+ return os.router({
25
+ getTheme: os.getTheme.handler(async ({ context }) => {
26
+ // context.user is guaranteed to be RealUser by contract meta
27
+ const userId = (context.user as RealUser).id;
28
+
29
+ // Query user theme preference
30
+ const preferences = await db
31
+ .select({ theme: schema.userThemePreference.theme })
32
+ .from(schema.userThemePreference)
33
+ .where(eq(schema.userThemePreference.userId, userId))
34
+ .limit(1);
35
+
36
+ // Return preference or default to 'system'
37
+ const theme = preferences[0]?.theme || "system";
38
+ return { theme: theme as "light" | "dark" | "system" };
39
+ }),
40
+
41
+ setTheme: os.setTheme.handler(async ({ input, context }) => {
42
+ // context.user is guaranteed to be RealUser by contract meta
43
+ const userId = (context.user as RealUser).id;
44
+ const { theme } = input;
45
+
46
+ // Upsert theme preference
47
+ await db
48
+ .insert(schema.userThemePreference)
49
+ .values([
50
+ {
51
+ userId,
52
+ theme,
53
+ updatedAt: new Date(),
54
+ },
55
+ ])
56
+ .onConflictDoUpdate({
57
+ target: [schema.userThemePreference.userId],
58
+ set: {
59
+ theme,
60
+ updatedAt: new Date(),
61
+ },
62
+ });
63
+ }),
64
+ });
65
+ };
66
+
67
+ export type ThemeRouter = ReturnType<typeof createThemeRouter>;
package/src/schema.ts ADDED
@@ -0,0 +1,8 @@
1
+ import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
2
+
3
+ // User theme preference table
4
+ export const userThemePreference = pgTable("user_theme_preference", {
5
+ userId: text("user_id").primaryKey(), // References user from auth system
6
+ theme: text("theme").notNull().default("system"), // 'light', 'dark', or 'system'
7
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
8
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": "@checkstack/tsconfig/backend.json",
3
+ "include": [
4
+ "src"
5
+ ]
6
+ }