@igstack/app-catalog-backend-core 6.12.0 → 6.12.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/dist/generated/prisma/client.mjs.map +1 -1
- package/dist/generated/prisma/internal/class.d.mts +12 -0
- package/dist/generated/prisma/internal/class.d.mts.map +1 -1
- package/dist/generated/prisma/internal/class.mjs +4 -4
- package/dist/generated/prisma/internal/class.mjs.map +1 -1
- package/dist/generated/prisma/internal/prismaNamespace.d.mts +90 -3
- package/dist/generated/prisma/internal/prismaNamespace.d.mts.map +1 -1
- package/dist/generated/prisma/models/DbCommunityTool.d.mts +289 -4
- package/dist/generated/prisma/models/DbCommunityTool.d.mts.map +1 -1
- package/dist/generated/prisma/models/DbCommunityToolOpen.d.mts +1171 -0
- package/dist/generated/prisma/models/DbCommunityToolOpen.d.mts.map +1 -0
- package/dist/generated/prisma/models.d.mts +2 -1
- package/dist/modules/communityTools/capture.d.mts +8 -1
- package/dist/modules/communityTools/capture.d.mts.map +1 -1
- package/dist/modules/communityTools/capture.mjs +40 -14
- package/dist/modules/communityTools/capture.mjs.map +1 -1
- package/dist/modules/communityTools/healthJob.mjs +3 -2
- package/dist/modules/communityTools/healthJob.mjs.map +1 -1
- package/dist/modules/communityTools/router.d.mts +14 -1
- package/dist/modules/communityTools/router.mjs +7 -2
- package/dist/modules/communityTools/router.mjs.map +1 -1
- package/dist/modules/communityTools/service.mjs +35 -81
- package/dist/modules/communityTools/service.mjs.map +1 -1
- package/dist/modules/communityTools/toView.mjs +3 -1
- package/dist/modules/communityTools/toView.mjs.map +1 -1
- package/dist/modules/communityTools/writeService.mjs +1 -0
- package/dist/modules/communityTools/writeService.mjs.map +1 -1
- package/dist/server/controller.d.mts +9 -0
- package/dist/server/controller.d.mts.map +1 -1
- package/package.json +4 -4
- package/prisma/schema.prisma +25 -2
- package/src/__tests__/communityTools.test.ts +87 -42
- package/src/__tests__/communityToolsCapture.test.ts +34 -0
- package/src/__tests__/communityToolsHealth.test.ts +62 -0
- package/src/__tests__/communityToolsWrite.test.ts +5 -1
- package/src/generated/prisma/browser.ts +6 -1
- package/src/generated/prisma/client.ts +6 -1
- package/src/generated/prisma/internal/class.ts +14 -4
- package/src/generated/prisma/internal/prismaNamespace.ts +90 -1
- package/src/generated/prisma/internal/prismaNamespaceBrowser.ts +14 -0
- package/src/generated/prisma/models/DbCommunityTool.ts +299 -2
- package/src/generated/prisma/models/DbCommunityToolOpen.ts +1383 -0
- package/src/generated/prisma/models/DbCommunityToolView.ts +1 -1
- package/src/generated/prisma/models.ts +1 -0
- package/src/modules/communityTools/capture.ts +54 -18
- package/src/modules/communityTools/healthJob.ts +7 -1
- package/src/modules/communityTools/router.ts +26 -1
- package/src/modules/communityTools/service.ts +48 -91
- package/src/modules/communityTools/toView.ts +2 -0
- package/src/modules/communityTools/writeService.ts +1 -0
|
@@ -16,7 +16,7 @@ import type * as Prisma from "../internal/prismaNamespace"
|
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* Model DbCommunityToolView
|
|
19
|
-
*
|
|
19
|
+
* Superseded by DbCommunityToolOpen and no longer written; kept so the schema stays additive.
|
|
20
20
|
*/
|
|
21
21
|
export type DbCommunityToolViewModel = runtime.Types.Result.DefaultSelection<Prisma.$DbCommunityToolViewPayload>
|
|
22
22
|
|
|
@@ -28,6 +28,7 @@ export type * from './models/DbCommunityToolOwner'
|
|
|
28
28
|
export type * from './models/DbCommunityToolLike'
|
|
29
29
|
export type * from './models/DbCommunityToolNamedOwner'
|
|
30
30
|
export type * from './models/DbCommunityToolTag'
|
|
31
|
+
export type * from './models/DbCommunityToolOpen'
|
|
31
32
|
export type * from './models/DbCommunityToolView'
|
|
32
33
|
export type * from './models/DbCommunityToolPin'
|
|
33
34
|
export type * from './commonInputTypes'
|
|
@@ -10,13 +10,23 @@ export type CaptureResult =
|
|
|
10
10
|
/** The page's `Last-Modified`: a static host sets it on each deploy. */
|
|
11
11
|
lastModified: Date | null
|
|
12
12
|
}
|
|
13
|
-
| {
|
|
13
|
+
| {
|
|
14
|
+
ok: false
|
|
15
|
+
error: string
|
|
16
|
+
/**
|
|
17
|
+
* No answer at all (a timeout, a refused connection), which may be the
|
|
18
|
+
* server's own network. Absent for a definitive failure: an HTTP error
|
|
19
|
+
* status, or a name that does not resolve.
|
|
20
|
+
*/
|
|
21
|
+
unreachable?: true
|
|
22
|
+
}
|
|
14
23
|
|
|
15
24
|
/**
|
|
16
25
|
* Visits a page and photographs it, reaching only `allowedHosts`.
|
|
17
26
|
*
|
|
18
27
|
* Resolves `{ ok: false }` when the page itself failed (no response, an error status, a
|
|
19
|
-
* timeout, a redirect off the list) — that is the tool's health
|
|
28
|
+
* timeout, a redirect off the list) — that is the tool's health; `unreachable` when
|
|
29
|
+
* it never answered. Throws when the
|
|
20
30
|
* browser could not be started, which is ours: the health job must not mark every tool
|
|
21
31
|
* broken because Chromium is missing on one pod.
|
|
22
32
|
*/
|
|
@@ -191,6 +201,7 @@ export async function capturePage(
|
|
|
191
201
|
return {
|
|
192
202
|
ok: false,
|
|
193
203
|
error: response ? `HTTP ${response.status()}` : 'No response',
|
|
204
|
+
...(!response && { unreachable: true as const }),
|
|
194
205
|
}
|
|
195
206
|
}
|
|
196
207
|
// `load` is only the HTML and its assets: a page that fetches its data after
|
|
@@ -240,6 +251,19 @@ export async function capturePage(
|
|
|
240
251
|
}
|
|
241
252
|
}
|
|
242
253
|
|
|
254
|
+
/**
|
|
255
|
+
* A capture that threw, from the browser or the network under it. Only a name
|
|
256
|
+
* that does not resolve is definitive; anything else got no answer at all.
|
|
257
|
+
*/
|
|
258
|
+
export function failureOf(
|
|
259
|
+
error: unknown,
|
|
260
|
+
): Extract<CaptureResult, { ok: false }> {
|
|
261
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
262
|
+
return /ENOTFOUND|ERR_NAME_NOT_RESOLVED/.test(message)
|
|
263
|
+
? { ok: false, error: message }
|
|
264
|
+
: { ok: false, error: message, unreachable: true }
|
|
265
|
+
}
|
|
266
|
+
|
|
243
267
|
/** An HTTP date header as a Date; null when absent or unreadable. */
|
|
244
268
|
export function httpDate(value: string | undefined): Date | null {
|
|
245
269
|
if (!value) return null
|
|
@@ -293,6 +317,9 @@ export function createPlaywrightCapturer(): ScreenshotCapturer | null {
|
|
|
293
317
|
viewport: VIEWPORT,
|
|
294
318
|
serviceWorkers: 'block',
|
|
295
319
|
})
|
|
320
|
+
// The fence answers the page itself, so the browser only sees that it
|
|
321
|
+
// failed; why it failed (no such name, or no answer) is kept here.
|
|
322
|
+
let navigationError: unknown = null
|
|
296
323
|
// Second layer under the resolver fence: per-request and per-hop checks.
|
|
297
324
|
await context.route('**/*', (route) =>
|
|
298
325
|
fenceRoute(
|
|
@@ -300,17 +327,25 @@ export function createPlaywrightCapturer(): ScreenshotCapturer | null {
|
|
|
300
327
|
url: route.request().url(),
|
|
301
328
|
abort: (code) => route.abort(code),
|
|
302
329
|
continue: () => route.continue(),
|
|
303
|
-
fetch: (hop, mode) =>
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
330
|
+
fetch: async (hop, mode) => {
|
|
331
|
+
try {
|
|
332
|
+
return await (mode === 'get'
|
|
333
|
+
? context.request.get(hop, {
|
|
334
|
+
maxRedirects: 0,
|
|
335
|
+
timeout: HOP_TIMEOUT_MS,
|
|
336
|
+
})
|
|
337
|
+
: route.fetch({
|
|
338
|
+
url: hop,
|
|
339
|
+
maxRedirects: 0,
|
|
340
|
+
timeout: HOP_TIMEOUT_MS,
|
|
341
|
+
}))
|
|
342
|
+
} catch (error) {
|
|
343
|
+
if (route.request().isNavigationRequest()) {
|
|
344
|
+
navigationError ??= error
|
|
345
|
+
}
|
|
346
|
+
throw error
|
|
347
|
+
}
|
|
348
|
+
},
|
|
314
349
|
fulfill: (response) => route.fulfill({ response }),
|
|
315
350
|
},
|
|
316
351
|
allowedHosts,
|
|
@@ -319,12 +354,13 @@ export function createPlaywrightCapturer(): ScreenshotCapturer | null {
|
|
|
319
354
|
// A screenshot needs no live socket, and `route` does not see them.
|
|
320
355
|
await context.routeWebSocket(/.*/, (ws) => ws.close())
|
|
321
356
|
const page = await context.newPage()
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
error: error instanceof Error ? error.message : String(error),
|
|
357
|
+
try {
|
|
358
|
+
return await capturePage(page, url, allowedHosts)
|
|
359
|
+
} catch (error) {
|
|
360
|
+
return failureOf(navigationError ?? error)
|
|
327
361
|
}
|
|
362
|
+
} catch (error) {
|
|
363
|
+
return failureOf(error)
|
|
328
364
|
} finally {
|
|
329
365
|
await browser.close()
|
|
330
366
|
}
|
|
@@ -28,6 +28,7 @@ interface CheckableTool {
|
|
|
28
28
|
failingSince: Date | null
|
|
29
29
|
archivedAt: Date | null
|
|
30
30
|
archivedBy: string | null
|
|
31
|
+
unreachableSince: Date | null
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
/** Stores the screenshot and, when it decodes, the favicon as assets. */
|
|
@@ -97,7 +98,11 @@ export async function checkTool(
|
|
|
97
98
|
where: { id: tool.id },
|
|
98
99
|
data: {
|
|
99
100
|
lastCheckAt: now,
|
|
100
|
-
...nextLifecycle(
|
|
101
|
+
...nextLifecycle(
|
|
102
|
+
tool,
|
|
103
|
+
result.ok ? 'ok' : result.unreachable ? 'unreachable' : 'failed',
|
|
104
|
+
now,
|
|
105
|
+
),
|
|
101
106
|
...(result.ok && (await storeCapture(tool.slug, result, now))),
|
|
102
107
|
// A visit without the header leaves the date it last had.
|
|
103
108
|
...(result.ok &&
|
|
@@ -143,6 +148,7 @@ export async function runHealthCheckOnce(
|
|
|
143
148
|
failingSince: true,
|
|
144
149
|
archivedAt: true,
|
|
145
150
|
archivedBy: true,
|
|
151
|
+
unreachableSince: true,
|
|
146
152
|
},
|
|
147
153
|
})
|
|
148
154
|
const due = tools.filter(
|
|
@@ -89,11 +89,36 @@ export function createCommunityToolsRouter(options?: CommunityToolsOptions) {
|
|
|
89
89
|
return tool
|
|
90
90
|
}),
|
|
91
91
|
|
|
92
|
+
/** The card opened. */
|
|
92
93
|
recordView: publicProcedure
|
|
93
94
|
.input(z.object({ slug: z.string() }))
|
|
94
95
|
.mutation(({ input, ctx }) => {
|
|
95
96
|
const actor = requireActor(ctx.actor)
|
|
96
|
-
return service.
|
|
97
|
+
return service.recordOpen(
|
|
98
|
+
input.slug,
|
|
99
|
+
'card',
|
|
100
|
+
actor,
|
|
101
|
+
ctx.user,
|
|
102
|
+
new Date(),
|
|
103
|
+
)
|
|
104
|
+
}),
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A click through to the site. The page leaves as it is sent, so the
|
|
108
|
+
* client posts it with `keepalive` rather than through the tRPC client.
|
|
109
|
+
*/
|
|
110
|
+
recordSiteOpen: publicProcedure
|
|
111
|
+
.input(z.object({ slug: z.string() }))
|
|
112
|
+
.mutation(async ({ input, ctx }) => {
|
|
113
|
+
const actor = requireActor(ctx.actor)
|
|
114
|
+
await service.recordOpen(
|
|
115
|
+
input.slug,
|
|
116
|
+
'site',
|
|
117
|
+
actor,
|
|
118
|
+
ctx.user,
|
|
119
|
+
new Date(),
|
|
120
|
+
)
|
|
121
|
+
return { ok: true }
|
|
97
122
|
}),
|
|
98
123
|
|
|
99
124
|
pins: protectedProcedure.query(({ ctx }) => {
|
|
@@ -34,28 +34,10 @@ export async function listTools(
|
|
|
34
34
|
})
|
|
35
35
|
const personMap = new Map(persons.map((p) => [p.slug, p]))
|
|
36
36
|
|
|
37
|
-
// Get view counts for all tools
|
|
38
|
-
const today = new Date(now)
|
|
39
|
-
today.setUTCHours(0, 0, 0, 0)
|
|
40
|
-
const thirtyDaysAgo = new Date(today)
|
|
41
|
-
thirtyDaysAgo.setUTCDate(thirtyDaysAgo.getUTCDate() - 29)
|
|
42
|
-
|
|
43
|
-
const viewCounts = await Promise.all(
|
|
44
|
-
toolIds.map(async (toolId) => {
|
|
45
|
-
const [views30d, viewsAll] = await Promise.all([
|
|
46
|
-
db.dbCommunityToolView.count({
|
|
47
|
-
where: { toolId, day: { gte: thirtyDaysAgo } },
|
|
48
|
-
}),
|
|
49
|
-
db.dbCommunityToolView.count({ where: { toolId } }),
|
|
50
|
-
])
|
|
51
|
-
return { toolId, views30d, viewsAll }
|
|
52
|
-
}),
|
|
53
|
-
)
|
|
54
37
|
const viewCountMap = new Map(
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
]),
|
|
38
|
+
await Promise.all(
|
|
39
|
+
toolIds.map(async (id) => [id, await openCounts(id, now)] as const),
|
|
40
|
+
),
|
|
59
41
|
)
|
|
60
42
|
|
|
61
43
|
return Promise.all(
|
|
@@ -111,22 +93,10 @@ export async function getTool(
|
|
|
111
93
|
})
|
|
112
94
|
const personMap = new Map(persons.map((p) => [p.slug, p]))
|
|
113
95
|
|
|
114
|
-
const today = new Date(now)
|
|
115
|
-
today.setUTCHours(0, 0, 0, 0)
|
|
116
|
-
const thirtyDaysAgo = new Date(today)
|
|
117
|
-
thirtyDaysAgo.setUTCDate(thirtyDaysAgo.getUTCDate() - 29)
|
|
118
|
-
|
|
119
|
-
const [views30d, viewsAll] = await Promise.all([
|
|
120
|
-
db.dbCommunityToolView.count({
|
|
121
|
-
where: { toolId: tool.id, day: { gte: thirtyDaysAgo } },
|
|
122
|
-
}),
|
|
123
|
-
db.dbCommunityToolView.count({ where: { toolId: tool.id } }),
|
|
124
|
-
])
|
|
125
|
-
|
|
126
96
|
return toView(
|
|
127
97
|
tool,
|
|
128
98
|
personMap,
|
|
129
|
-
|
|
99
|
+
await openCounts(tool.id, now),
|
|
130
100
|
user,
|
|
131
101
|
isAdmin,
|
|
132
102
|
now,
|
|
@@ -134,54 +104,58 @@ export async function getTool(
|
|
|
134
104
|
)
|
|
135
105
|
}
|
|
136
106
|
|
|
137
|
-
|
|
107
|
+
/** Opens since the first of the last 30 UTC days, and ever. */
|
|
108
|
+
async function openCounts(
|
|
109
|
+
toolId: string,
|
|
110
|
+
now: Date,
|
|
111
|
+
): Promise<{ views30d: number; viewsAll: number }> {
|
|
112
|
+
const db = getDbClient()
|
|
113
|
+
const since = new Date(now)
|
|
114
|
+
since.setUTCHours(0, 0, 0, 0)
|
|
115
|
+
since.setUTCDate(since.getUTCDate() - 29)
|
|
116
|
+
const [views30d, viewsAll] = await Promise.all([
|
|
117
|
+
db.dbCommunityToolOpen.count({ where: { toolId, at: { gte: since } } }),
|
|
118
|
+
db.dbCommunityToolOpen.count({ where: { toolId } }),
|
|
119
|
+
])
|
|
120
|
+
return { views30d, viewsAll }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** More opens than this by one visitor of one tool in a minute are ignored. */
|
|
124
|
+
const OPENS_PER_MINUTE = 30
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Counts one card open or click through to the site: every one counts, short
|
|
128
|
+
* of a visitor opening the same tool more than OPENS_PER_MINUTE times a minute.
|
|
129
|
+
*/
|
|
130
|
+
export async function recordOpen(
|
|
138
131
|
slug: string,
|
|
132
|
+
kind: 'card' | 'site',
|
|
139
133
|
actor: Actor,
|
|
140
134
|
user: User | null,
|
|
141
135
|
now: Date,
|
|
142
136
|
): Promise<{ views30d: number; viewsAll: number }> {
|
|
143
137
|
const db = getDbClient()
|
|
138
|
+
const toolId = await requireToolId(slug)
|
|
144
139
|
|
|
145
|
-
const
|
|
146
|
-
if (!tool) {
|
|
147
|
-
throw new Error('Tool not found')
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
const day = new Date(now)
|
|
151
|
-
day.setUTCHours(0, 0, 0, 0)
|
|
152
|
-
|
|
153
|
-
await db.dbCommunityToolView.upsert({
|
|
140
|
+
const recent = await db.dbCommunityToolOpen.count({
|
|
154
141
|
where: {
|
|
155
|
-
|
|
156
|
-
toolId: tool.id,
|
|
157
|
-
actorHash: actor.hash,
|
|
158
|
-
day,
|
|
159
|
-
},
|
|
160
|
-
},
|
|
161
|
-
create: {
|
|
162
|
-
toolId: tool.id,
|
|
142
|
+
toolId,
|
|
163
143
|
actorHash: actor.hash,
|
|
164
|
-
|
|
165
|
-
day,
|
|
166
|
-
at: now,
|
|
167
|
-
},
|
|
168
|
-
update: {
|
|
169
|
-
at: now,
|
|
170
|
-
userId: user?.id ?? null,
|
|
144
|
+
at: { gt: new Date(now.getTime() - 60_000) },
|
|
171
145
|
},
|
|
172
146
|
})
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
return
|
|
147
|
+
if (recent < OPENS_PER_MINUTE) {
|
|
148
|
+
await db.dbCommunityToolOpen.create({
|
|
149
|
+
data: {
|
|
150
|
+
toolId,
|
|
151
|
+
kind,
|
|
152
|
+
actorHash: actor.hash,
|
|
153
|
+
userId: user?.id ?? null,
|
|
154
|
+
at: now,
|
|
155
|
+
},
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
return openCounts(toolId, now)
|
|
185
159
|
}
|
|
186
160
|
|
|
187
161
|
export async function listPins(
|
|
@@ -218,27 +192,10 @@ export async function listPins(
|
|
|
218
192
|
})
|
|
219
193
|
const personMap = new Map(persons.map((p) => [p.slug, p]))
|
|
220
194
|
|
|
221
|
-
const today = new Date(now)
|
|
222
|
-
today.setUTCHours(0, 0, 0, 0)
|
|
223
|
-
const thirtyDaysAgo = new Date(today)
|
|
224
|
-
thirtyDaysAgo.setUTCDate(thirtyDaysAgo.getUTCDate() - 29)
|
|
225
|
-
|
|
226
|
-
const viewCounts = await Promise.all(
|
|
227
|
-
toolIds.map(async (toolId) => {
|
|
228
|
-
const [views30d, viewsAll] = await Promise.all([
|
|
229
|
-
db.dbCommunityToolView.count({
|
|
230
|
-
where: { toolId, day: { gte: thirtyDaysAgo } },
|
|
231
|
-
}),
|
|
232
|
-
db.dbCommunityToolView.count({ where: { toolId } }),
|
|
233
|
-
])
|
|
234
|
-
return { toolId, views30d, viewsAll }
|
|
235
|
-
}),
|
|
236
|
-
)
|
|
237
195
|
const viewCountMap = new Map(
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
]),
|
|
196
|
+
await Promise.all(
|
|
197
|
+
toolIds.map(async (id) => [id, await openCounts(id, now)] as const),
|
|
198
|
+
),
|
|
242
199
|
)
|
|
243
200
|
|
|
244
201
|
return Promise.all(
|
|
@@ -64,6 +64,7 @@ export async function toView(
|
|
|
64
64
|
archivedAt: tool.archivedAt,
|
|
65
65
|
archivedBy: tool.archivedBy,
|
|
66
66
|
archivedReason: tool.archivedReason,
|
|
67
|
+
unreachableSince: tool.unreachableSince,
|
|
67
68
|
},
|
|
68
69
|
now,
|
|
69
70
|
)
|
|
@@ -114,6 +115,7 @@ export async function toView(
|
|
|
114
115
|
faviconId: tool.faviconAssetId ?? null,
|
|
115
116
|
capturedAt: tool.capturedAt?.toISOString() ?? null,
|
|
116
117
|
capturePending,
|
|
118
|
+
serverUnreachable: tool.unreachableSince !== null,
|
|
117
119
|
canEdit,
|
|
118
120
|
}
|
|
119
121
|
}
|
|
@@ -319,6 +319,7 @@ export async function editTool(
|
|
|
319
319
|
// automatic archive says nothing about the new one.
|
|
320
320
|
const newSite = host !== tool.host && {
|
|
321
321
|
failingSince: null,
|
|
322
|
+
unreachableSince: null,
|
|
322
323
|
lastCheckAt: null,
|
|
323
324
|
...(tool.archivedBy === 'auto' && {
|
|
324
325
|
archivedAt: null,
|