@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.
Files changed (40) hide show
  1. package/dist/db/syncAppCatalog.mjs +13 -1
  2. package/dist/db/syncAppCatalog.mjs.map +1 -1
  3. package/dist/generated/prisma/internal/class.mjs +4 -4
  4. package/dist/generated/prisma/internal/class.mjs.map +1 -1
  5. package/dist/generated/prisma/internal/prismaNamespace.d.mts +2 -0
  6. package/dist/generated/prisma/internal/prismaNamespace.d.mts.map +1 -1
  7. package/dist/generated/prisma/models/DbResource.d.mts +85 -1
  8. package/dist/generated/prisma/models/DbResource.d.mts.map +1 -1
  9. package/dist/index.d.mts +2 -1
  10. package/dist/index.mjs +2 -1
  11. package/dist/modules/appCatalog/freshness.mjs +5 -3
  12. package/dist/modules/appCatalog/freshness.mjs.map +1 -1
  13. package/dist/modules/appCatalog/service.d.mts +7 -0
  14. package/dist/modules/appCatalog/service.d.mts.map +1 -0
  15. package/dist/modules/appCatalog/service.mjs +20 -2
  16. package/dist/modules/appCatalog/service.mjs.map +1 -1
  17. package/dist/modules/assets/upsertAsset.mjs +12 -1
  18. package/dist/modules/assets/upsertAsset.mjs.map +1 -1
  19. package/dist/server/controller.d.mts +8 -0
  20. package/dist/server/controller.d.mts.map +1 -1
  21. package/dist/server/controller.mjs +7 -1
  22. package/dist/server/controller.mjs.map +1 -1
  23. package/dist/types/common/appCatalogTypes.d.mts +10 -0
  24. package/dist/types/common/appCatalogTypes.d.mts.map +1 -1
  25. package/package.json +7 -7
  26. package/prisma/migrations/20260813000000_add_ai_memory/migration.sql +2 -0
  27. package/prisma/schema.prisma +6 -2
  28. package/src/__tests__/assetMime.test.ts +104 -14
  29. package/src/__tests__/freshness.test.ts +56 -17
  30. package/src/db/syncAppCatalog.ts +25 -0
  31. package/src/generated/prisma/internal/class.ts +4 -4
  32. package/src/generated/prisma/internal/prismaNamespace.ts +2 -0
  33. package/src/generated/prisma/internal/prismaNamespaceBrowser.ts +2 -0
  34. package/src/generated/prisma/models/DbResource.ts +87 -1
  35. package/src/index.ts +1 -0
  36. package/src/modules/appCatalog/freshness.ts +29 -11
  37. package/src/modules/appCatalog/service.ts +35 -4
  38. package/src/modules/assets/upsertAsset.ts +20 -4
  39. package/src/server/controller.ts +11 -0
  40. 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.16.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": "^1.0.25",
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": "^6.0.37",
34
- "better-auth": "^1.4.18",
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.16.0",
49
- "@igstack/app-catalog-table-sync": "0.16.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": "8ef46d75c527a6d4a00965f7529e000c03cf0bc6",
76
+ "gitHead": "304d9c364de826d155069ac01370baffb0620c78",
77
77
  "scripts": {
78
78
  "build": "tsdown",
79
79
  "build:lenient": "tsdown",
@@ -0,0 +1,2 @@
1
+ -- AlterTable
2
+ ALTER TABLE "DbResource" ADD COLUMN "aiMemory" TEXT;
@@ -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 and when the
168
- // next check is due. Nullable — apps that have never been scanned carry null.
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
- describe('upsertAsset mimeType repair', () => {
22
- const fakePrisma = (existing: { id: string; mimeType: string } | null) => {
23
- const update = vi.fn().mockResolvedValue(existing)
24
- const create = vi.fn().mockResolvedValue({ id: 'created' })
25
- const prisma = {
26
- dbAsset: {
27
- findUnique: vi.fn().mockResolvedValue(existing),
28
- update,
29
- create,
30
- },
31
- } as unknown as PrismaClient
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
- return { prisma, update, create }
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 date and not-stale when never checked', () => {
13
- expect(computeFreshness(null, null, NOW)).toEqual({
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(null, iso(-30 * DAY), NOW)).toEqual({
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(iso(-DAY), iso(DAY), NOW)
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(computeFreshness(iso(-10 * DAY), iso(-3 * DAY), NOW).isStale).toBe(
33
- false,
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(computeFreshness(iso(-15 * DAY), iso(-8 * DAY), NOW).isStale).toBe(
40
- true,
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
- iso(-8 * DAY),
49
- new Date(NOW - STALE_GRACE_MS).toISOString(),
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(computeFreshness(iso(-100 * DAY), null, NOW).isStale).toBe(false)
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(computeFreshness(iso(-DAY), 'not-a-date', NOW).isStale).toBe(false)
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
  })
@@ -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