@igstack/app-catalog-backend-core 0.16.0 → 0.17.1-alpha-20260821043201
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/db/syncAppCatalog.mjs +13 -1
- package/dist/db/syncAppCatalog.mjs.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 +2 -0
- package/dist/generated/prisma/internal/prismaNamespace.d.mts.map +1 -1
- package/dist/generated/prisma/models/DbResource.d.mts +85 -1
- package/dist/generated/prisma/models/DbResource.d.mts.map +1 -1
- package/dist/index.d.mts +2 -1
- package/dist/index.mjs +2 -1
- package/dist/modules/appCatalog/freshness.mjs +5 -3
- package/dist/modules/appCatalog/freshness.mjs.map +1 -1
- package/dist/modules/appCatalog/service.d.mts +7 -0
- package/dist/modules/appCatalog/service.d.mts.map +1 -0
- package/dist/modules/appCatalog/service.mjs +20 -2
- package/dist/modules/appCatalog/service.mjs.map +1 -1
- package/dist/modules/assets/upsertAsset.mjs +12 -1
- package/dist/modules/assets/upsertAsset.mjs.map +1 -1
- package/dist/server/controller.d.mts +8 -0
- package/dist/server/controller.d.mts.map +1 -1
- package/dist/server/controller.mjs +7 -1
- package/dist/server/controller.mjs.map +1 -1
- package/dist/types/common/appCatalogTypes.d.mts +10 -0
- package/dist/types/common/appCatalogTypes.d.mts.map +1 -1
- package/package.json +7 -7
- package/prisma/migrations/20260813000000_add_ai_memory/migration.sql +2 -0
- package/prisma/schema.prisma +6 -2
- package/src/__tests__/assetMime.test.ts +104 -14
- package/src/__tests__/freshness.test.ts +56 -17
- package/src/db/syncAppCatalog.ts +25 -0
- package/src/generated/prisma/internal/class.ts +4 -4
- package/src/generated/prisma/internal/prismaNamespace.ts +2 -0
- package/src/generated/prisma/internal/prismaNamespaceBrowser.ts +2 -0
- package/src/generated/prisma/models/DbResource.ts +87 -1
- package/src/index.ts +1 -0
- package/src/modules/appCatalog/freshness.ts +29 -11
- package/src/modules/appCatalog/service.ts +35 -4
- package/src/modules/assets/upsertAsset.ts +20 -4
- package/src/server/controller.ts +11 -0
- package/src/types/common/appCatalogTypes.ts +10 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@igstack/app-catalog-backend-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.1-alpha-20260821043201",
|
|
4
4
|
"description": "Backend core library for App Catalog",
|
|
5
5
|
"homepage": "https://github.com/lislon/app-catalog",
|
|
6
6
|
"repository": {
|
|
@@ -24,14 +24,14 @@
|
|
|
24
24
|
"prisma.config.ts"
|
|
25
25
|
],
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@ai-sdk/mcp": "
|
|
27
|
+
"@ai-sdk/mcp": "1.0.70",
|
|
28
28
|
"@better-auth/prisma-adapter": "^1.5.3",
|
|
29
29
|
"@kubernetes/client-node": "^1.2.0",
|
|
30
30
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
31
31
|
"@trpc/client": "^11.8.1",
|
|
32
32
|
"@trpc/server": "^11.4.2",
|
|
33
|
-
"ai": "
|
|
34
|
-
"better-auth": "
|
|
33
|
+
"ai": "6.0.250",
|
|
34
|
+
"better-auth": ">=1.4.18 <1.7.0",
|
|
35
35
|
"cors": "^2.8.5",
|
|
36
36
|
"dotenv": "^17.3.1",
|
|
37
37
|
"dotenv-defaults": "5.0.2",
|
|
@@ -45,8 +45,8 @@
|
|
|
45
45
|
"tsyringe": "^4.10.0",
|
|
46
46
|
"yaml": "^2.8.0",
|
|
47
47
|
"zod": "^4.3.5",
|
|
48
|
-
"@igstack/app-catalog-shared-core": "0.
|
|
49
|
-
"@igstack/app-catalog-table-sync": "0.
|
|
48
|
+
"@igstack/app-catalog-shared-core": "0.17.1-alpha-20260821043201",
|
|
49
|
+
"@igstack/app-catalog-table-sync": "0.17.1-alpha-20260821043201"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@tanstack/vite-config": "^0.4.3",
|
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
"engines": {
|
|
74
74
|
"node": ">=24"
|
|
75
75
|
},
|
|
76
|
-
"gitHead": "
|
|
76
|
+
"gitHead": "304d9c364de826d155069ac01370baffb0620c78",
|
|
77
77
|
"scripts": {
|
|
78
78
|
"build": "tsdown",
|
|
79
79
|
"build:lenient": "tsdown",
|
package/prisma/schema.prisma
CHANGED
|
@@ -147,6 +147,7 @@ model DbResource {
|
|
|
147
147
|
/// [AppDeprecation] - Deprecation info with optional replacement slug
|
|
148
148
|
deprecated Json?
|
|
149
149
|
aiPrompt String?
|
|
150
|
+
aiMemory String?
|
|
150
151
|
urlIssues String[] @default([])
|
|
151
152
|
/// [TierVariant[]]
|
|
152
153
|
tiers Json?
|
|
@@ -164,10 +165,13 @@ model DbResource {
|
|
|
164
165
|
accessComments String?
|
|
165
166
|
/// [SubResourceExtra]
|
|
166
167
|
extra Json?
|
|
167
|
-
// Freshness (autoupdate scan): when sources were last verified
|
|
168
|
-
//
|
|
168
|
+
// Freshness (autoupdate scan): when sources were last verified, when the next
|
|
169
|
+
// check is due, and when the source content last actually changed (what the UI
|
|
170
|
+
// shows as "Updated" — a source can be re-read without changing).
|
|
171
|
+
// Nullable — apps that have never been scanned carry null.
|
|
169
172
|
lastCheckedAt DateTime?
|
|
170
173
|
nextCheckAfter DateTime?
|
|
174
|
+
lastContentChangeAt DateTime?
|
|
171
175
|
createdAt DateTime @default(now())
|
|
172
176
|
updatedAt DateTime @updatedAt
|
|
173
177
|
|
|
@@ -18,25 +18,37 @@ describe('parseAssetMeta mime derivation', () => {
|
|
|
18
18
|
})
|
|
19
19
|
})
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
21
|
+
const fakePrisma = (
|
|
22
|
+
existing: {
|
|
23
|
+
id: string
|
|
24
|
+
mimeType: string
|
|
25
|
+
checksum: string
|
|
26
|
+
} | null,
|
|
27
|
+
) => {
|
|
28
|
+
const update = vi.fn().mockResolvedValue(existing)
|
|
29
|
+
const create = vi.fn().mockResolvedValue({ id: 'created' })
|
|
30
|
+
const prisma = {
|
|
31
|
+
dbAsset: {
|
|
32
|
+
findUnique: vi.fn().mockResolvedValue(existing),
|
|
33
|
+
update,
|
|
34
|
+
create,
|
|
35
|
+
},
|
|
36
|
+
} as unknown as PrismaClient
|
|
32
37
|
|
|
33
|
-
|
|
34
|
-
|
|
38
|
+
return { prisma, update, create }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('upsertAsset mimeType repair', () => {
|
|
42
|
+
it('rewrites a stale mimeType on an already-stored asset (same bytes, wrong derivation)', async () => {
|
|
43
|
+
const { checksum } = await parseAssetMeta({
|
|
44
|
+
buffer: SVG,
|
|
45
|
+
originalFilename: 'example-icon.svg',
|
|
46
|
+
})
|
|
35
47
|
|
|
36
|
-
it('rewrites a stale mimeType on an already-stored asset', async () => {
|
|
37
48
|
const { prisma, update } = fakePrisma({
|
|
38
49
|
id: 'existing',
|
|
39
50
|
mimeType: 'image/svg',
|
|
51
|
+
checksum,
|
|
40
52
|
})
|
|
41
53
|
|
|
42
54
|
const id = await upsertAsset({
|
|
@@ -55,9 +67,15 @@ describe('upsertAsset mimeType repair', () => {
|
|
|
55
67
|
})
|
|
56
68
|
|
|
57
69
|
it('leaves an already-correct asset untouched', async () => {
|
|
70
|
+
const { checksum } = await parseAssetMeta({
|
|
71
|
+
buffer: SVG,
|
|
72
|
+
originalFilename: 'example-icon.svg',
|
|
73
|
+
})
|
|
74
|
+
|
|
58
75
|
const { prisma, update } = fakePrisma({
|
|
59
76
|
id: 'existing',
|
|
60
77
|
mimeType: 'image/svg+xml',
|
|
78
|
+
checksum,
|
|
61
79
|
})
|
|
62
80
|
|
|
63
81
|
const id = await upsertAsset({
|
|
@@ -72,3 +90,75 @@ describe('upsertAsset mimeType repair', () => {
|
|
|
72
90
|
expect(update).not.toHaveBeenCalled()
|
|
73
91
|
})
|
|
74
92
|
})
|
|
93
|
+
|
|
94
|
+
describe('upsertAsset content replacement', () => {
|
|
95
|
+
const OLD_SVG = SVG
|
|
96
|
+
const NEW_PNG = Buffer.from(
|
|
97
|
+
// 1x1 transparent PNG
|
|
98
|
+
'89504e470d0a1a0a0000000d494844520000000100000001080600000' +
|
|
99
|
+
'01f15c4890000000a49444154789c6360000002000100ffff03000006' +
|
|
100
|
+
'0005579259de0000000049454e44ae426082',
|
|
101
|
+
'hex',
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
it('rewrites content/checksum/fileSize/mimeType when the stored asset name is reused for different bytes', async () => {
|
|
105
|
+
const oldMeta = await parseAssetMeta({
|
|
106
|
+
buffer: OLD_SVG,
|
|
107
|
+
originalFilename: 'example-icon.svg',
|
|
108
|
+
})
|
|
109
|
+
const newMeta = await parseAssetMeta({
|
|
110
|
+
buffer: NEW_PNG,
|
|
111
|
+
originalFilename: 'example-icon.png',
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
const { prisma, update } = fakePrisma({
|
|
115
|
+
id: 'existing',
|
|
116
|
+
mimeType: oldMeta.mimeType,
|
|
117
|
+
checksum: oldMeta.checksum,
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
const id = await upsertAsset({
|
|
121
|
+
prisma,
|
|
122
|
+
buffer: NEW_PNG,
|
|
123
|
+
name: 'example-icon',
|
|
124
|
+
originalFilename: 'example-icon.png',
|
|
125
|
+
assetType: 'icon',
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
expect(id).toBe('existing')
|
|
129
|
+
expect(update).toHaveBeenCalledWith({
|
|
130
|
+
where: { id: 'existing' },
|
|
131
|
+
data: {
|
|
132
|
+
mimeType: 'image/png',
|
|
133
|
+
content: new Uint8Array(NEW_PNG),
|
|
134
|
+
checksum: newMeta.checksum,
|
|
135
|
+
fileSize: newMeta.fileSize,
|
|
136
|
+
width: newMeta.width,
|
|
137
|
+
height: newMeta.height,
|
|
138
|
+
},
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('does not call update when the same bytes are re-synced (checksum unchanged)', async () => {
|
|
143
|
+
const meta = await parseAssetMeta({
|
|
144
|
+
buffer: OLD_SVG,
|
|
145
|
+
originalFilename: 'example-icon.svg',
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
const { prisma, update } = fakePrisma({
|
|
149
|
+
id: 'existing',
|
|
150
|
+
mimeType: meta.mimeType,
|
|
151
|
+
checksum: meta.checksum,
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
await upsertAsset({
|
|
155
|
+
prisma,
|
|
156
|
+
buffer: OLD_SVG,
|
|
157
|
+
name: 'example-icon',
|
|
158
|
+
originalFilename: 'example-icon.svg',
|
|
159
|
+
assetType: 'icon',
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
expect(update).not.toHaveBeenCalled()
|
|
163
|
+
})
|
|
164
|
+
})
|
|
@@ -9,55 +9,94 @@ const iso = (msFromNow: number) => new Date(NOW + msFromNow).toISOString()
|
|
|
9
9
|
const DAY = 24 * 60 * 60 * 1000
|
|
10
10
|
|
|
11
11
|
describe('computeFreshness', () => {
|
|
12
|
-
it('returns null
|
|
13
|
-
expect(computeFreshness(
|
|
12
|
+
it('returns null dates and not-stale when never checked', () => {
|
|
13
|
+
expect(computeFreshness({}, NOW)).toEqual({
|
|
14
14
|
lastCheckedAt: null,
|
|
15
|
+
lastContentChangeAt: null,
|
|
15
16
|
isStale: false,
|
|
16
17
|
})
|
|
17
18
|
// nextCheckAfter present but never checked → still nothing to show
|
|
18
|
-
expect(computeFreshness(
|
|
19
|
+
expect(computeFreshness({ nextCheckAfter: iso(-30 * DAY) }, NOW)).toEqual({
|
|
19
20
|
lastCheckedAt: null,
|
|
21
|
+
lastContentChangeAt: null,
|
|
20
22
|
isStale: false,
|
|
21
23
|
})
|
|
22
24
|
})
|
|
23
25
|
|
|
24
26
|
it('is fresh when the next check is still in the future', () => {
|
|
25
|
-
const r = computeFreshness(
|
|
27
|
+
const r = computeFreshness(
|
|
28
|
+
{ lastCheckedAt: iso(-DAY), nextCheckAfter: iso(DAY) },
|
|
29
|
+
NOW,
|
|
30
|
+
)
|
|
26
31
|
expect(r.isStale).toBe(false)
|
|
27
32
|
expect(r.lastCheckedAt).toBe(iso(-DAY))
|
|
28
33
|
})
|
|
29
34
|
|
|
30
35
|
it('is fresh when overdue but within the grace period', () => {
|
|
31
36
|
// due 3 days ago, grace is 7 days → not stale yet
|
|
32
|
-
expect(
|
|
33
|
-
|
|
34
|
-
|
|
37
|
+
expect(
|
|
38
|
+
computeFreshness(
|
|
39
|
+
{ lastCheckedAt: iso(-10 * DAY), nextCheckAfter: iso(-3 * DAY) },
|
|
40
|
+
NOW,
|
|
41
|
+
).isStale,
|
|
42
|
+
).toBe(false)
|
|
35
43
|
})
|
|
36
44
|
|
|
37
45
|
it('is stale once past due by more than the grace period', () => {
|
|
38
46
|
// due 8 days ago > 7-day grace → stale
|
|
39
|
-
expect(
|
|
40
|
-
|
|
41
|
-
|
|
47
|
+
expect(
|
|
48
|
+
computeFreshness(
|
|
49
|
+
{ lastCheckedAt: iso(-15 * DAY), nextCheckAfter: iso(-8 * DAY) },
|
|
50
|
+
NOW,
|
|
51
|
+
).isStale,
|
|
52
|
+
).toBe(true)
|
|
42
53
|
})
|
|
43
54
|
|
|
44
55
|
it('treats exactly-grace as not yet stale (strictly greater than)', () => {
|
|
45
|
-
const dueAt = iso(-0) // due now
|
|
46
|
-
// now - due === 0; push due back exactly grace → boundary is not stale
|
|
47
56
|
const atBoundary = computeFreshness(
|
|
48
|
-
|
|
49
|
-
|
|
57
|
+
{
|
|
58
|
+
lastCheckedAt: iso(-8 * DAY),
|
|
59
|
+
nextCheckAfter: new Date(NOW - STALE_GRACE_MS).toISOString(),
|
|
60
|
+
},
|
|
50
61
|
NOW,
|
|
51
62
|
)
|
|
52
63
|
expect(atBoundary.isStale).toBe(false)
|
|
53
|
-
void dueAt
|
|
54
64
|
})
|
|
55
65
|
|
|
56
66
|
it('is not stale when nextCheckAfter is missing (cannot judge overdue)', () => {
|
|
57
|
-
expect(
|
|
67
|
+
expect(
|
|
68
|
+
computeFreshness({ lastCheckedAt: iso(-100 * DAY) }, NOW).isStale,
|
|
69
|
+
).toBe(false)
|
|
58
70
|
})
|
|
59
71
|
|
|
60
72
|
it('ignores an unparseable nextCheckAfter', () => {
|
|
61
|
-
expect(
|
|
73
|
+
expect(
|
|
74
|
+
computeFreshness(
|
|
75
|
+
{ lastCheckedAt: iso(-DAY), nextCheckAfter: 'not-a-date' },
|
|
76
|
+
NOW,
|
|
77
|
+
).isStale,
|
|
78
|
+
).toBe(false)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('passes through when the content last actually changed', () => {
|
|
82
|
+
// The UI shows this instead of lastCheckedAt: a source re-read 20 times
|
|
83
|
+
// without changing was still "Updated" 90 days ago.
|
|
84
|
+
const r = computeFreshness(
|
|
85
|
+
{
|
|
86
|
+
lastCheckedAt: iso(-DAY),
|
|
87
|
+
nextCheckAfter: iso(DAY),
|
|
88
|
+
lastContentChangeAt: iso(-90 * DAY),
|
|
89
|
+
},
|
|
90
|
+
NOW,
|
|
91
|
+
)
|
|
92
|
+
expect(r.lastContentChangeAt).toBe(iso(-90 * DAY))
|
|
93
|
+
expect(r.lastCheckedAt).toBe(iso(-DAY))
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('leaves lastContentChangeAt null for entries predating the field', () => {
|
|
97
|
+
// Older rows have no content-change timestamp; the frontend falls back to
|
|
98
|
+
// lastCheckedAt rather than showing nothing.
|
|
99
|
+
const r = computeFreshness({ lastCheckedAt: iso(-DAY) }, NOW)
|
|
100
|
+
expect(r.lastContentChangeAt).toBeNull()
|
|
62
101
|
})
|
|
63
102
|
})
|
package/src/db/syncAppCatalog.ts
CHANGED
|
@@ -296,6 +296,8 @@ export async function syncAppCatalog(
|
|
|
296
296
|
screenshotIds: resource.screenshotIds ?? [],
|
|
297
297
|
deprecated: resource.deprecated ?? null,
|
|
298
298
|
aiPrompt: resource.aiPrompt ?? null,
|
|
299
|
+
// aiMemory intentionally excluded from bulk sync — it is AI-owned and
|
|
300
|
+
// preserved across restarts. Static-config values are seeded below (if DB is null).
|
|
299
301
|
urlIssues: resource.urlIssues ?? [],
|
|
300
302
|
tiers: resource.tiers ?? null,
|
|
301
303
|
// Fields from former SubResource
|
|
@@ -313,6 +315,9 @@ export async function syncAppCatalog(
|
|
|
313
315
|
nextCheckAfter: resource.nextCheckAfter
|
|
314
316
|
? new Date(resource.nextCheckAfter)
|
|
315
317
|
: null,
|
|
318
|
+
lastContentChangeAt: resource.lastContentChangeAt
|
|
319
|
+
? new Date(resource.lastContentChangeAt)
|
|
320
|
+
: null,
|
|
316
321
|
}
|
|
317
322
|
})
|
|
318
323
|
|
|
@@ -362,6 +367,26 @@ export async function syncAppCatalog(
|
|
|
362
367
|
...TABLE_SYNC_MAGAZINE.SourceReference,
|
|
363
368
|
}).sync(allSourceRefs)
|
|
364
369
|
|
|
370
|
+
// Seed aiMemory only for resources where the static config provides an initial value
|
|
371
|
+
// and the DB currently has none. This preserves AI-written updates across restarts.
|
|
372
|
+
const resourcesWithAiMemorySeed = sortedResources.filter(
|
|
373
|
+
(r) => r.aiMemory != null && r.aiMemory.trim() !== '',
|
|
374
|
+
)
|
|
375
|
+
if (resourcesWithAiMemorySeed.length > 0) {
|
|
376
|
+
for (const resource of resourcesWithAiMemorySeed) {
|
|
377
|
+
const slug =
|
|
378
|
+
resource.slug ||
|
|
379
|
+
resource.displayName
|
|
380
|
+
.toLowerCase()
|
|
381
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
382
|
+
.replace(/^-+|-+$/g, '')
|
|
383
|
+
await prisma.dbResource.updateMany({
|
|
384
|
+
where: { slug, aiMemory: null },
|
|
385
|
+
data: { aiMemory: resource.aiMemory },
|
|
386
|
+
})
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
365
390
|
// Get actual synced data to calculate stats
|
|
366
391
|
const actual = result.getActual()
|
|
367
392
|
|