@budibase/worker 3.27.5 → 3.28.1

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@budibase/worker",
3
3
  "email": "hi@budibase.com",
4
- "version": "3.27.5",
4
+ "version": "3.28.1",
5
5
  "description": "Budibase background service",
6
6
  "main": "src/index.ts",
7
7
  "repository": {
@@ -107,5 +107,5 @@
107
107
  }
108
108
  }
109
109
  },
110
- "gitHead": "46316dfbbf8e41a547bc8a4977ccd819a7515b9a"
110
+ "gitHead": "e034f570c9fbb13b51fa2178e74764b1c3182761"
111
111
  }
@@ -0,0 +1,77 @@
1
+ import fetch from "node-fetch"
2
+ import { type GetGitHubStarsResponse, type UserCtx } from "@budibase/types"
3
+ import { cache } from "@budibase/backend-core"
4
+
5
+ const CACHE_TTL_MS = 6 * 60 * 60 * 1000
6
+ const FAILURE_TTL_MS = 5 * 60 * 1000
7
+ const GITHUB_TIMEOUT_MS = 5000
8
+ const GITHUB_REPO_URL = "https://api.github.com/repos/budibase/budibase"
9
+ const USER_AGENT = "Budibase"
10
+
11
+ const CACHE_KEY = "global:github:stars"
12
+ const RETENTION_TTL_SECONDS = 30 * 24 * 60 * 60
13
+
14
+ interface StarsCacheEnvelope {
15
+ value: GetGitHubStarsResponse
16
+ expiresAt: number
17
+ }
18
+
19
+ export async function getStars(ctx: UserCtx<void, GetGitHubStarsResponse>) {
20
+ const envelope = (await cache.get(CACHE_KEY, {
21
+ useTenancy: false,
22
+ })) as StarsCacheEnvelope | null
23
+ if (envelope && envelope.expiresAt > Date.now()) {
24
+ ctx.body = envelope.value
25
+ return
26
+ }
27
+
28
+ try {
29
+ const response = await fetch(GITHUB_REPO_URL, {
30
+ headers: {
31
+ Accept: "application/vnd.github+json",
32
+ "User-Agent": USER_AGENT,
33
+ },
34
+ timeout: GITHUB_TIMEOUT_MS,
35
+ })
36
+
37
+ if (!response.ok) {
38
+ throw new Error(`GitHub response: ${response.status}`)
39
+ }
40
+
41
+ const json = (await response.json()) as { stargazers_count?: number }
42
+ const stars = json.stargazers_count
43
+
44
+ if (typeof stars !== "number") {
45
+ throw new Error("GitHub stars missing")
46
+ }
47
+
48
+ const value: GetGitHubStarsResponse = {
49
+ stars,
50
+ fetchedAt: new Date().toISOString(),
51
+ }
52
+ const toStore: StarsCacheEnvelope = {
53
+ value,
54
+ expiresAt: Date.now() + CACHE_TTL_MS,
55
+ }
56
+
57
+ await cache.store(CACHE_KEY, toStore, RETENTION_TTL_SECONDS, {
58
+ useTenancy: false,
59
+ })
60
+
61
+ ctx.body = value
62
+ } catch (err) {
63
+ console.error("Failed to fetch GitHub stars", err)
64
+
65
+ const value = envelope?.value || { stars: null, fetchedAt: null }
66
+ const toStore: StarsCacheEnvelope = {
67
+ value,
68
+ expiresAt: Date.now() + FAILURE_TTL_MS,
69
+ }
70
+
71
+ await cache.store(CACHE_KEY, toStore, RETENTION_TTL_SECONDS, {
72
+ useTenancy: false,
73
+ })
74
+
75
+ ctx.body = value
76
+ }
77
+ }
@@ -0,0 +1,193 @@
1
+ import { type GetGitHubStarsResponse } from "@budibase/types"
2
+
3
+ jest.mock("node-fetch", () => jest.fn())
4
+
5
+ interface CacheOpts {
6
+ useTenancy?: boolean
7
+ }
8
+
9
+ interface TestCtx {
10
+ body: GetGitHubStarsResponse | undefined
11
+ }
12
+
13
+ describe("GitHub controller", () => {
14
+ let cacheGet: jest.Mock
15
+ let cacheStore: jest.Mock
16
+ let cacheState: Map<string, unknown>
17
+ let consoleErrorSpy: jest.SpyInstance
18
+
19
+ beforeEach(() => {
20
+ jest.resetModules()
21
+ jest.clearAllMocks()
22
+
23
+ jest.useFakeTimers()
24
+ jest.setSystemTime(new Date("2020-01-01T00:00:00.000Z"))
25
+
26
+ consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {})
27
+
28
+ cacheState = new Map()
29
+ jest.doMock("@budibase/backend-core", () => {
30
+ cacheGet = jest.fn(async (key: string, opts?: CacheOpts) => {
31
+ if (opts?.useTenancy !== false) {
32
+ throw new Error("Expected global cache usage")
33
+ }
34
+ return cacheState.get(key) ?? null
35
+ })
36
+
37
+ cacheStore = jest.fn(
38
+ async (
39
+ key: string,
40
+ value: unknown,
41
+ _ttl?: number,
42
+ opts?: CacheOpts
43
+ ) => {
44
+ if (opts?.useTenancy !== false) {
45
+ throw new Error("Expected global cache usage")
46
+ }
47
+ cacheState.set(key, value)
48
+ }
49
+ )
50
+
51
+ return {
52
+ cache: {
53
+ get: cacheGet,
54
+ store: cacheStore,
55
+ },
56
+ }
57
+ })
58
+ })
59
+
60
+ afterEach(() => {
61
+ consoleErrorSpy.mockRestore()
62
+ jest.useRealTimers()
63
+ })
64
+
65
+ const getFetchMock = () => {
66
+ return jest.requireMock("node-fetch") as unknown as jest.Mock
67
+ }
68
+
69
+ const makeCtx = () => {
70
+ const ctx: TestCtx = { body: undefined }
71
+ return ctx
72
+ }
73
+
74
+ function assertHasBody(
75
+ ctx: TestCtx
76
+ ): asserts ctx is { body: GetGitHubStarsResponse } {
77
+ if (!ctx.body) {
78
+ throw new Error("Expected ctx.body to be set")
79
+ }
80
+ }
81
+
82
+ it("adds a request timeout", async () => {
83
+ const fetchMock = getFetchMock()
84
+ fetchMock.mockResolvedValue({
85
+ ok: true,
86
+ status: 200,
87
+ json: async () => ({ stargazers_count: 123 }),
88
+ })
89
+
90
+ const { getStars } = require("../github")
91
+ const ctx = makeCtx()
92
+ await getStars(ctx)
93
+
94
+ expect(fetchMock).toHaveBeenCalledTimes(1)
95
+ expect(fetchMock.mock.calls[0][1]).toEqual(
96
+ expect.objectContaining({ timeout: 5000 })
97
+ )
98
+ assertHasBody(ctx)
99
+ expect(ctx.body.stars).toEqual(123)
100
+
101
+ expect(cacheGet).toHaveBeenCalledWith("global:github:stars", {
102
+ useTenancy: false,
103
+ })
104
+ expect(cacheStore).toHaveBeenCalledWith(
105
+ "global:github:stars",
106
+ expect.any(Object),
107
+ expect.any(Number),
108
+ { useTenancy: false }
109
+ )
110
+ })
111
+
112
+ it("caches successful responses", async () => {
113
+ const fetchMock = getFetchMock()
114
+ fetchMock.mockResolvedValue({
115
+ ok: true,
116
+ status: 200,
117
+ json: async () => ({ stargazers_count: 999 }),
118
+ })
119
+
120
+ const { getStars } = require("../github")
121
+ const ctx1 = makeCtx()
122
+ await getStars(ctx1)
123
+ const ctx2 = makeCtx()
124
+ await getStars(ctx2)
125
+
126
+ expect(fetchMock).toHaveBeenCalledTimes(1)
127
+ expect(ctx2.body).toEqual(ctx1.body)
128
+ })
129
+
130
+ it("serves cached value and sets a short failure TTL on error", async () => {
131
+ const fetchMock = getFetchMock()
132
+ fetchMock.mockRejectedValue(new Error("timeout"))
133
+
134
+ const { getStars } = require("../github")
135
+ const ctx1 = makeCtx()
136
+ await getStars(ctx1)
137
+ const ctx2 = makeCtx()
138
+ await getStars(ctx2)
139
+
140
+ expect(fetchMock).toHaveBeenCalledTimes(1)
141
+ expect(ctx1.body).toEqual({ stars: null, fetchedAt: null })
142
+ expect(ctx2.body).toEqual(ctx1.body)
143
+ })
144
+
145
+ it("returns last known value on refresh failure", async () => {
146
+ const fetchMock = getFetchMock()
147
+ fetchMock.mockResolvedValue({
148
+ ok: true,
149
+ status: 200,
150
+ json: async () => ({ stargazers_count: 111 }),
151
+ })
152
+
153
+ const { getStars } = require("../github")
154
+ const ctx1 = makeCtx()
155
+ await getStars(ctx1)
156
+
157
+ jest.setSystemTime(new Date(Date.now() + 6 * 60 * 60 * 1000 + 1))
158
+ fetchMock.mockRejectedValue(new Error("github down"))
159
+
160
+ const ctx2 = makeCtx()
161
+ await getStars(ctx2)
162
+
163
+ expect(fetchMock).toHaveBeenCalledTimes(2)
164
+ expect(ctx2.body).toEqual(ctx1.body)
165
+ })
166
+
167
+ it("retries after failure backoff expires", async () => {
168
+ const fetchMock = getFetchMock()
169
+ fetchMock.mockRejectedValue(new Error("timeout"))
170
+
171
+ const { getStars } = require("../github")
172
+ const ctx1 = makeCtx()
173
+ await getStars(ctx1)
174
+ const ctx2 = makeCtx()
175
+ await getStars(ctx2)
176
+
177
+ expect(fetchMock).toHaveBeenCalledTimes(1)
178
+
179
+ jest.setSystemTime(new Date(Date.now() + 5 * 60 * 1000 + 1))
180
+ fetchMock.mockResolvedValue({
181
+ ok: true,
182
+ status: 200,
183
+ json: async () => ({ stargazers_count: 222 }),
184
+ })
185
+
186
+ const ctx3 = makeCtx()
187
+ await getStars(ctx3)
188
+
189
+ expect(fetchMock).toHaveBeenCalledTimes(2)
190
+ assertHasBody(ctx3)
191
+ expect(ctx3.body.stars).toEqual(222)
192
+ })
193
+ })
@@ -268,10 +268,12 @@ export const adminUser = async (
268
268
  })
269
269
  }
270
270
 
271
- export const countByApp = async (ctx: UserCtx<void, CountUserResponse>) => {
272
- const appId = ctx.params.appId
271
+ export const countByWorkspace = async (
272
+ ctx: UserCtx<void, CountUserResponse>
273
+ ) => {
274
+ const workspaceId = ctx.params.workspaceId
273
275
  try {
274
- ctx.body = await userSdk.db.countUsersByApp(appId)
276
+ ctx.body = await userSdk.db.countUsersByWorkspace(workspaceId)
275
277
  } catch (err: any) {
276
278
  ctx.throw(err.status || 400, err)
277
279
  }
@@ -0,0 +1,4 @@
1
+ import * as controller from "../../controllers/global/github"
2
+ import { builderRoutes } from "../endpointGroups"
3
+
4
+ builderRoutes.get("/api/global/github/stars", controller.getStars)
@@ -92,7 +92,7 @@ adminRoutes
92
92
 
93
93
  builderOrAdminRoutes
94
94
  .get("/api/global/users", controller.fetch)
95
- .get("/api/global/users/count/:appId", controller.countByApp)
95
+ .get("/api/global/users/count/:workspaceId", controller.countByWorkspace)
96
96
  .get("/api/global/users/invites", controller.getUserInvites)
97
97
  .get("/api/global/users/:id", controller.find)
98
98
  .post(
@@ -7,6 +7,7 @@ import "./global/auth"
7
7
  import "./global/configs"
8
8
  import "./global/email"
9
9
  import "./global/events"
10
+ import "./global/github"
10
11
  import "./global/license"
11
12
  import "./global/roles"
12
13
  import "./global/self"