@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 +47 -0
- package/drizzle/0000_milky_paladin.sql +5 -0
- package/drizzle/meta/0000_snapshot.json +52 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +8 -0
- package/package.json +31 -0
- package/src/index.ts +50 -0
- package/src/router.test.ts +127 -0
- package/src/router.ts +67 -0
- package/src/schema.ts +8 -0
- package/tsconfig.json +6 -0
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,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
|
+
}
|
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
|
+
});
|