@igstack/app-catalog-backend-core 0.18.2 → 0.18.3

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.
@@ -175,13 +175,12 @@ async function syncAppCatalog(resources, tagsDefinitions, approvalMethods, scree
175
175
  for (const resource of resources) {
176
176
  if (!resource.catalogAddedAt) continue;
177
177
  const addedAt = new Date(resource.catalogAddedAt);
178
- await prisma.dbResource.updateMany({
179
- where: {
180
- slug: resource.slug || resource.displayName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""),
181
- createdAt: { gt: addedAt }
182
- },
183
- data: { createdAt: addedAt }
184
- });
178
+ const slug = resource.slug || resource.displayName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
179
+ await prisma.$executeRaw`
180
+ UPDATE "DbResource"
181
+ SET "createdAt" = ${addedAt}
182
+ WHERE "slug" = ${slug} AND "createdAt" > ${addedAt}
183
+ `;
185
184
  }
186
185
  const slugs = dbResources.map((a) => a.slug);
187
186
  const resourceRows = await prisma.dbResource.findMany({
@@ -1 +1 @@
1
- {"version":3,"file":"syncAppCatalog.mjs","names":[],"sources":["../../src/db/syncAppCatalog.ts"],"sourcesContent":["import type {\n GroupingTagDefinition,\n Resource,\n} from '../types/common/appCatalogTypes'\nimport { getDbClient } from './client'\nimport { TABLE_SYNC_MAGAZINE } from './tableSyncMagazine'\nimport { tableSyncPrisma } from './tableSyncPrismaAdapter'\nimport { readFile, readdir, stat } from 'node:fs/promises'\nimport { group } from 'radashi'\nimport { upsertAsset } from '../modules/assets/upsertAsset'\nimport type { ApprovalMethod, Group, Person } from '../types'\nimport type { PrismaClient } from '../generated/prisma/client'\nimport { naturalSort } from '../utils/naturalSort'\nimport { parseSourceSlug } from '../utils/parseSourceSlug'\n\nexport interface SyncAppCatalogResult {\n created: number\n updated: number\n deleted: number\n total: number\n}\n\ninterface AssetSyncResult {\n screenshotIds: string[]\n iconName: string | null\n}\n\nfunction isFileNotFoundError(error: unknown): boolean {\n return (\n error instanceof Error &&\n 'code' in error &&\n (error as NodeJS.ErrnoException).code === 'ENOENT'\n )\n}\n\nasync function processAssetDirectory(\n dirPath: string,\n appSlug: string,\n assetType: 'screenshot' | 'icon',\n prisma: PrismaClient,\n): Promise<string[]> {\n try {\n const files = await readdir(dirPath)\n const sortedFiles = naturalSort(files)\n const assetIds: string[] = []\n\n for (let i = 0; i < sortedFiles.length; i++) {\n const fileName = sortedFiles[i]\n if (!fileName) continue\n\n const assetName =\n assetType === 'screenshot'\n ? `${appSlug}-screenshot-${i + 1}`\n : `${appSlug}-icon`\n\n const id = await upsertAsset({\n prisma,\n buffer: await readFile(`${dirPath}/${fileName}`),\n originalFilename: fileName,\n name: assetName,\n assetType,\n })\n assetIds.push(id)\n\n // For icons, only process the first file\n if (assetType === 'icon') {\n break\n }\n }\n\n return assetIds\n } catch (error: unknown) {\n if (isFileNotFoundError(error)) {\n return []\n }\n throw error\n }\n}\n\nasync function syncAppAssets(\n appSlug: string,\n appPath: string,\n prisma: PrismaClient,\n): Promise<AssetSyncResult> {\n const screenshotIds = await processAssetDirectory(\n `${appPath}/screenshots`,\n appSlug,\n 'screenshot',\n prisma,\n )\n\n const iconIds = await processAssetDirectory(\n `${appPath}/icons`,\n appSlug,\n 'icon',\n prisma,\n )\n\n return {\n screenshotIds,\n iconName: iconIds.length > 0 ? `${appSlug}-icon` : null,\n }\n}\n\nasync function syncAssetsFromFileSystem(\n resources: Resource[],\n allAppsAssetsPath: string,\n) {\n const appDirectories = await readdir(allAppsAssetsPath)\n const prisma = getDbClient()\n const bySlug = group(resources, (a) => a.slug)\n\n for (const appDirName of appDirectories) {\n try {\n const stats = await stat(`${allAppsAssetsPath}/${appDirName}`)\n if (!stats.isDirectory()) {\n continue\n }\n } catch (error: unknown) {\n if (isFileNotFoundError(error)) {\n continue\n }\n throw error\n }\n\n const appSlug = appDirName\n if (!bySlug[appSlug]) {\n throw new Error(\n `App '${appSlug}' does not exist in the app catalog. Existing apps: ${Object.keys(bySlug).join(', ')}`,\n )\n }\n\n try {\n const { screenshotIds, iconName } = await syncAppAssets(\n appSlug,\n `${allAppsAssetsPath}/${appDirName}`,\n prisma,\n )\n\n const updateData: {\n screenshotIds?: string[]\n iconName?: string | null\n } = {}\n\n if (screenshotIds.length > 0) {\n updateData.screenshotIds = screenshotIds\n }\n if (iconName !== null) {\n updateData.iconName = iconName\n }\n\n if (Object.keys(updateData).length > 0) {\n await prisma.dbResource.update({\n where: { slug: appSlug },\n data: updateData,\n })\n }\n } catch (error: unknown) {\n const errorMessage =\n error instanceof Error ? error.message : String(error)\n throw new Error(\n `Error while upserting assets for app '${appSlug}': ${errorMessage}`,\n )\n }\n }\n}\n\n/**\n * Optional data to sync alongside the core app catalog.\n */\nexport interface SyncAppCatalogOptions {\n persons?: Person[]\n groups?: Group[]\n}\n\n/**\n * Syncs app catalog data to the database using table sync.\n * This will create new resources, update existing ones, and delete any that are no longer in the input.\n *\n * Note: Call connectDb() before and disconnectDb() after if running in a script.\n */\nexport async function syncAppCatalog(\n resources: Resource[],\n tagsDefinitions: GroupingTagDefinition[],\n approvalMethods: ApprovalMethod[],\n screenshotsPath?: string,\n options?: SyncAppCatalogOptions,\n): Promise<SyncAppCatalogResult> {\n try {\n const prisma = getDbClient()\n\n // Sync Persons first (groups depend on persons via memberships)\n if (options?.persons) {\n const dbPersons = options.persons.map((p) => ({\n slug: p.slug,\n firstName: p.firstName,\n lastName: p.lastName,\n email: p.email ?? null,\n }))\n await tableSyncPrisma({\n prisma,\n ...TABLE_SYNC_MAGAZINE.DbPerson,\n }).sync(dbPersons)\n }\n\n // Sync Groups (without memberships first)\n if (options?.groups) {\n const dbGroups = options.groups.map((g) => ({\n slug: g.slug,\n displayName: g.displayName ?? null,\n email: g.email ?? null,\n }))\n await tableSyncPrisma({\n prisma,\n ...TABLE_SYNC_MAGAZINE.DbGroup,\n }).sync(dbGroups)\n\n // Now sync GroupMemberships\n const allMemberships = options.groups.flatMap((g) =>\n g.memberSlugs.map((personSlug) => ({\n groupSlug: g.slug,\n personSlug,\n })),\n )\n await tableSyncPrisma({\n prisma,\n ...TABLE_SYNC_MAGAZINE.DbGroupMembership,\n }).sync(allMemberships)\n }\n\n await tableSyncPrisma({\n prisma,\n ...TABLE_SYNC_MAGAZINE.DbApprovalMethod,\n }).sync(approvalMethods)\n\n const sync = tableSyncPrisma({\n prisma,\n ...TABLE_SYNC_MAGAZINE.DbResource,\n })\n\n await tableSyncPrisma({\n prisma,\n ...TABLE_SYNC_MAGAZINE.DbAppTagDefinition,\n }).sync(tagsDefinitions)\n\n // Collect all unique source slugs for sync\n const uniqueSourceSlugs = new Set<string>()\n for (const resource of resources) {\n for (const source of resource.sources ?? []) {\n const url = typeof source === 'string' ? source : source.url\n const sourceSlug = parseSourceSlug(url)\n uniqueSourceSlugs.add(sourceSlug)\n }\n }\n\n // Sync Source entries using tableSyncPrisma\n const sources = Array.from(uniqueSourceSlugs).map((slug) => ({\n slug,\n userPrompt: null,\n }))\n await tableSyncPrisma({\n prisma,\n ...TABLE_SYNC_MAGAZINE.Source,\n }).sync(sources)\n\n // Sort resources: parents first (no parentSlug), then children — for FK integrity\n const sortedResources = [...resources].sort((a, b) => {\n const aIsChild = a.parentSlug ? 1 : 0\n const bIsChild = b.parentSlug ? 1 : 0\n return aIsChild - bIsChild\n })\n\n // Transform Resource to DbResource format (scalar fields only)\n const dbResources = sortedResources.map((resource) => {\n const slug =\n resource.slug ||\n resource.displayName\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n\n return {\n slug,\n type: resource.type ?? 'application',\n displayName: resource.displayName,\n abbreviation: resource.abbreviation ?? null,\n nicknames: resource.nicknames ?? [],\n description: resource.description,\n teams: resource.teams ?? [],\n accessRequest: resource.accessRequest ?? null,\n notes: resource.notes ?? null,\n tags: resource.tags ?? [],\n appUrl: resource.appUrl ?? null,\n links: resource.links ?? null,\n iconName: resource.iconName ?? null,\n screenshotIds: resource.screenshotIds ?? [],\n deprecated: resource.deprecated ?? null,\n aiPrompt: resource.aiPrompt ?? null,\n // aiMemory intentionally excluded from bulk sync — it is AI-owned and\n // preserved across restarts. Static-config values are seeded below (if DB is null).\n urlIssues: resource.urlIssues ?? [],\n tiers: resource.tiers ?? null,\n // Fields from former SubResource\n parentSlug: resource.parentSlug ?? null,\n tier: resource.tier ?? null,\n familySlug: resource.familySlug ?? null,\n aliases: resource.aliases ?? [],\n ownerPersonSlug: resource.ownerPersonSlug ?? null,\n accessMaintainerGroupSlugs: resource.accessMaintainerGroupSlugs ?? [],\n accessComments: resource.accessComments ?? null,\n extra: resource.extra ?? null,\n lastCheckedAt: resource.lastCheckedAt\n ? new Date(resource.lastCheckedAt)\n : null,\n nextCheckAfter: resource.nextCheckAfter\n ? new Date(resource.nextCheckAfter)\n : null,\n lastContentChangeAt: resource.lastContentChangeAt\n ? new Date(resource.lastContentChangeAt)\n : null,\n createdAt: resource.catalogAddedAt\n ? new Date(resource.catalogAddedAt)\n : undefined,\n }\n })\n\n // Sync resources\n const result = await sync.sync(dbResources)\n\n // Backfill catalogAddedAt → DB createdAt. The table-sync comparison treats\n // createdAt as just another field, but Prisma's update path may silently\n // drop it because it carries @default(now()). This dedicated pass uses\n // updateMany with a WHERE guard so it only fires when the DB date is newer\n // than the static catalogAddedAt (e.g. after a DB rebuild), and is a no-op\n // once the dates are already correct.\n for (const resource of resources) {\n if (!resource.catalogAddedAt) continue\n const addedAt = new Date(resource.catalogAddedAt)\n await prisma.dbResource.updateMany({\n where: {\n slug:\n resource.slug ||\n resource.displayName\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, ''),\n createdAt: { gt: addedAt },\n },\n data: { createdAt: addedAt },\n })\n }\n\n // Resolve slug -> id for synced resources so SourceReference can reference by resourceId\n const slugs = dbResources.map((a) => a.slug)\n const resourceRows = await prisma.dbResource.findMany({\n where: { slug: { in: slugs } },\n select: { slug: true, id: true },\n })\n const slugToId = Object.fromEntries(resourceRows.map((r) => [r.slug, r.id]))\n\n // Build allSourceRefs with resourceId (slug already resolved to id)\n const allSourceRefs = sortedResources.flatMap((resource) => {\n const resourceSlug =\n resource.slug ||\n resource.displayName\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n const resourceId = slugToId[resourceSlug]\n if (!resourceId) {\n throw new Error(\n `Resource '${resourceSlug}' has no id after sync. Existing slugs: ${Object.keys(slugToId).join(', ')}`,\n )\n }\n\n return (resource.sources ?? []).map((source) => {\n const url = typeof source === 'string' ? source : source.url\n const sourceSlug = parseSourceSlug(url)\n return {\n resourceId,\n sourceSlug,\n url,\n parseDate: null,\n excerpts: [],\n userPrompt: null,\n }\n })\n })\n\n // Then sync all SourceReferences (with set semantics: old ones deleted, new ones created)\n await tableSyncPrisma({\n prisma,\n ...TABLE_SYNC_MAGAZINE.SourceReference,\n }).sync(allSourceRefs)\n\n // Seed aiMemory only for resources where the static config provides an initial value\n // and the DB currently has none. This preserves AI-written updates across restarts.\n const resourcesWithAiMemorySeed = sortedResources.filter(\n (r) => r.aiMemory != null && r.aiMemory.trim() !== '',\n )\n if (resourcesWithAiMemorySeed.length > 0) {\n for (const resource of resourcesWithAiMemorySeed) {\n const slug =\n resource.slug ||\n resource.displayName\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n await prisma.dbResource.updateMany({\n where: { slug, aiMemory: null },\n data: { aiMemory: resource.aiMemory },\n })\n }\n }\n\n // Get actual synced data to calculate stats\n const actual = result.getActual()\n\n if (screenshotsPath) {\n await syncAssetsFromFileSystem(resources, screenshotsPath)\n } else {\n console.warn('Do not sync screenhots')\n }\n\n return {\n created:\n actual.length - resources.length + (resources.length - actual.length),\n updated: 0, // TableSync doesn't expose this directly\n deleted: 0, // TableSync doesn't expose this directly\n total: actual.length,\n }\n } catch (error) {\n // Wrap error with context\n const errorMessage = error instanceof Error ? error.message : String(error)\n const errorStack = error instanceof Error ? error.stack : undefined\n\n throw new Error(\n `Error syncing app catalog: ${errorMessage}\\n\\nDetails:\\n${errorStack || 'No stack trace available'}`,\n )\n }\n}\n"],"mappings":";;;;;;;;;;AA2BA,SAAS,oBAAoB,OAAyB;AACpD,QACE,iBAAiB,SACjB,UAAU,SACT,MAAgC,SAAS;;AAI9C,eAAe,sBACb,SACA,SACA,WACA,QACmB;AACnB,KAAI;EAEF,MAAM,cAAc,YADN,MAAM,QAAQ,QAAQ,CACE;EACtC,MAAM,WAAqB,EAAE;AAE7B,OAAK,IAAI,IAAI,GAAG,IAAI,YAAY,QAAQ,KAAK;GAC3C,MAAM,WAAW,YAAY;AAC7B,OAAI,CAAC,SAAU;GAEf,MAAM,YACJ,cAAc,eACV,GAAG,QAAQ,cAAc,IAAI,MAC7B,GAAG,QAAQ;GAEjB,MAAM,KAAK,MAAM,YAAY;IAC3B;IACA,QAAQ,MAAM,SAAS,GAAG,QAAQ,GAAG,WAAW;IAChD,kBAAkB;IAClB,MAAM;IACN;IACD,CAAC;AACF,YAAS,KAAK,GAAG;AAGjB,OAAI,cAAc,OAChB;;AAIJ,SAAO;UACA,OAAgB;AACvB,MAAI,oBAAoB,MAAM,CAC5B,QAAO,EAAE;AAEX,QAAM;;;AAIV,eAAe,cACb,SACA,SACA,QAC0B;AAe1B,QAAO;EACL,eAfoB,MAAM,sBAC1B,GAAG,QAAQ,eACX,SACA,cACA,OACD;EAWC,WATc,MAAM,sBACpB,GAAG,QAAQ,SACX,SACA,QACA,OACD,EAImB,SAAS,IAAI,GAAG,QAAQ,SAAS;EACpD;;AAGH,eAAe,yBACb,WACA,mBACA;CACA,MAAM,iBAAiB,MAAM,QAAQ,kBAAkB;CACvD,MAAM,SAAS,aAAa;CAC5B,MAAM,SAAS,MAAM,YAAY,MAAM,EAAE,KAAK;AAE9C,MAAK,MAAM,cAAc,gBAAgB;AACvC,MAAI;AAEF,OAAI,EADU,MAAM,KAAK,GAAG,kBAAkB,GAAG,aAAa,EACnD,aAAa,CACtB;WAEK,OAAgB;AACvB,OAAI,oBAAoB,MAAM,CAC5B;AAEF,SAAM;;EAGR,MAAM,UAAU;AAChB,MAAI,CAAC,OAAO,SACV,OAAM,IAAI,MACR,QAAQ,QAAQ,sDAAsD,OAAO,KAAK,OAAO,CAAC,KAAK,KAAK,GACrG;AAGH,MAAI;GACF,MAAM,EAAE,eAAe,aAAa,MAAM,cACxC,SACA,GAAG,kBAAkB,GAAG,cACxB,OACD;GAED,MAAM,aAGF,EAAE;AAEN,OAAI,cAAc,SAAS,EACzB,YAAW,gBAAgB;AAE7B,OAAI,aAAa,KACf,YAAW,WAAW;AAGxB,OAAI,OAAO,KAAK,WAAW,CAAC,SAAS,EACnC,OAAM,OAAO,WAAW,OAAO;IAC7B,OAAO,EAAE,MAAM,SAAS;IACxB,MAAM;IACP,CAAC;WAEG,OAAgB;GACvB,MAAM,eACJ,iBAAiB,QAAQ,MAAM,UAAU,OAAO,MAAM;AACxD,SAAM,IAAI,MACR,yCAAyC,QAAQ,KAAK,eACvD;;;;;;;;;;AAmBP,eAAsB,eACpB,WACA,iBACA,iBACA,iBACA,SAC+B;AAC/B,KAAI;EACF,MAAM,SAAS,aAAa;AAG5B,MAAI,SAAS,SAAS;GACpB,MAAM,YAAY,QAAQ,QAAQ,KAAK,OAAO;IAC5C,MAAM,EAAE;IACR,WAAW,EAAE;IACb,UAAU,EAAE;IACZ,OAAO,EAAE,SAAS;IACnB,EAAE;AACH,SAAM,gBAAgB;IACpB;IACA,GAAG,oBAAoB;IACxB,CAAC,CAAC,KAAK,UAAU;;AAIpB,MAAI,SAAS,QAAQ;GACnB,MAAM,WAAW,QAAQ,OAAO,KAAK,OAAO;IAC1C,MAAM,EAAE;IACR,aAAa,EAAE,eAAe;IAC9B,OAAO,EAAE,SAAS;IACnB,EAAE;AACH,SAAM,gBAAgB;IACpB;IACA,GAAG,oBAAoB;IACxB,CAAC,CAAC,KAAK,SAAS;GAGjB,MAAM,iBAAiB,QAAQ,OAAO,SAAS,MAC7C,EAAE,YAAY,KAAK,gBAAgB;IACjC,WAAW,EAAE;IACb;IACD,EAAE,CACJ;AACD,SAAM,gBAAgB;IACpB;IACA,GAAG,oBAAoB;IACxB,CAAC,CAAC,KAAK,eAAe;;AAGzB,QAAM,gBAAgB;GACpB;GACA,GAAG,oBAAoB;GACxB,CAAC,CAAC,KAAK,gBAAgB;EAExB,MAAM,OAAO,gBAAgB;GAC3B;GACA,GAAG,oBAAoB;GACxB,CAAC;AAEF,QAAM,gBAAgB;GACpB;GACA,GAAG,oBAAoB;GACxB,CAAC,CAAC,KAAK,gBAAgB;EAGxB,MAAM,oCAAoB,IAAI,KAAa;AAC3C,OAAK,MAAM,YAAY,UACrB,MAAK,MAAM,UAAU,SAAS,WAAW,EAAE,EAAE;GAE3C,MAAM,aAAa,gBADP,OAAO,WAAW,WAAW,SAAS,OAAO,IAClB;AACvC,qBAAkB,IAAI,WAAW;;EAKrC,MAAM,UAAU,MAAM,KAAK,kBAAkB,CAAC,KAAK,UAAU;GAC3D;GACA,YAAY;GACb,EAAE;AACH,QAAM,gBAAgB;GACpB;GACA,GAAG,oBAAoB;GACxB,CAAC,CAAC,KAAK,QAAQ;EAGhB,MAAM,kBAAkB,CAAC,GAAG,UAAU,CAAC,MAAM,GAAG,MAAM;AAGpD,WAFiB,EAAE,aAAa,IAAI,MACnB,EAAE,aAAa,IAAI;IAEpC;EAGF,MAAM,cAAc,gBAAgB,KAAK,aAAa;AAQpD,UAAO;IACL,MAPA,SAAS,QACT,SAAS,YACN,aAAa,CACb,QAAQ,eAAe,IAAI,CAC3B,QAAQ,YAAY,GAAG;IAI1B,MAAM,SAAS,QAAQ;IACvB,aAAa,SAAS;IACtB,cAAc,SAAS,gBAAgB;IACvC,WAAW,SAAS,aAAa,EAAE;IACnC,aAAa,SAAS;IACtB,OAAO,SAAS,SAAS,EAAE;IAC3B,eAAe,SAAS,iBAAiB;IACzC,OAAO,SAAS,SAAS;IACzB,MAAM,SAAS,QAAQ,EAAE;IACzB,QAAQ,SAAS,UAAU;IAC3B,OAAO,SAAS,SAAS;IACzB,UAAU,SAAS,YAAY;IAC/B,eAAe,SAAS,iBAAiB,EAAE;IAC3C,YAAY,SAAS,cAAc;IACnC,UAAU,SAAS,YAAY;IAG/B,WAAW,SAAS,aAAa,EAAE;IACnC,OAAO,SAAS,SAAS;IAEzB,YAAY,SAAS,cAAc;IACnC,MAAM,SAAS,QAAQ;IACvB,YAAY,SAAS,cAAc;IACnC,SAAS,SAAS,WAAW,EAAE;IAC/B,iBAAiB,SAAS,mBAAmB;IAC7C,4BAA4B,SAAS,8BAA8B,EAAE;IACrE,gBAAgB,SAAS,kBAAkB;IAC3C,OAAO,SAAS,SAAS;IACzB,eAAe,SAAS,gBACpB,IAAI,KAAK,SAAS,cAAc,GAChC;IACJ,gBAAgB,SAAS,iBACrB,IAAI,KAAK,SAAS,eAAe,GACjC;IACJ,qBAAqB,SAAS,sBAC1B,IAAI,KAAK,SAAS,oBAAoB,GACtC;IACJ,WAAW,SAAS,iBAChB,IAAI,KAAK,SAAS,eAAe,GACjC;IACL;IACD;EAGF,MAAM,SAAS,MAAM,KAAK,KAAK,YAAY;AAQ3C,OAAK,MAAM,YAAY,WAAW;AAChC,OAAI,CAAC,SAAS,eAAgB;GAC9B,MAAM,UAAU,IAAI,KAAK,SAAS,eAAe;AACjD,SAAM,OAAO,WAAW,WAAW;IACjC,OAAO;KACL,MACE,SAAS,QACT,SAAS,YACN,aAAa,CACb,QAAQ,eAAe,IAAI,CAC3B,QAAQ,YAAY,GAAG;KAC5B,WAAW,EAAE,IAAI,SAAS;KAC3B;IACD,MAAM,EAAE,WAAW,SAAS;IAC7B,CAAC;;EAIJ,MAAM,QAAQ,YAAY,KAAK,MAAM,EAAE,KAAK;EAC5C,MAAM,eAAe,MAAM,OAAO,WAAW,SAAS;GACpD,OAAO,EAAE,MAAM,EAAE,IAAI,OAAO,EAAE;GAC9B,QAAQ;IAAE,MAAM;IAAM,IAAI;IAAM;GACjC,CAAC;EACF,MAAM,WAAW,OAAO,YAAY,aAAa,KAAK,MAAM,CAAC,EAAE,MAAM,EAAE,GAAG,CAAC,CAAC;EAG5E,MAAM,gBAAgB,gBAAgB,SAAS,aAAa;GAC1D,MAAM,eACJ,SAAS,QACT,SAAS,YACN,aAAa,CACb,QAAQ,eAAe,IAAI,CAC3B,QAAQ,YAAY,GAAG;GAC5B,MAAM,aAAa,SAAS;AAC5B,OAAI,CAAC,WACH,OAAM,IAAI,MACR,aAAa,aAAa,0CAA0C,OAAO,KAAK,SAAS,CAAC,KAAK,KAAK,GACrG;AAGH,WAAQ,SAAS,WAAW,EAAE,EAAE,KAAK,WAAW;IAC9C,MAAM,MAAM,OAAO,WAAW,WAAW,SAAS,OAAO;AAEzD,WAAO;KACL;KACA,YAHiB,gBAAgB,IAAI;KAIrC;KACA,WAAW;KACX,UAAU,EAAE;KACZ,YAAY;KACb;KACD;IACF;AAGF,QAAM,gBAAgB;GACpB;GACA,GAAG,oBAAoB;GACxB,CAAC,CAAC,KAAK,cAAc;EAItB,MAAM,4BAA4B,gBAAgB,QAC/C,MAAM,EAAE,YAAY,QAAQ,EAAE,SAAS,MAAM,KAAK,GACpD;AACD,MAAI,0BAA0B,SAAS,EACrC,MAAK,MAAM,YAAY,2BAA2B;GAChD,MAAM,OACJ,SAAS,QACT,SAAS,YACN,aAAa,CACb,QAAQ,eAAe,IAAI,CAC3B,QAAQ,YAAY,GAAG;AAC5B,SAAM,OAAO,WAAW,WAAW;IACjC,OAAO;KAAE;KAAM,UAAU;KAAM;IAC/B,MAAM,EAAE,UAAU,SAAS,UAAU;IACtC,CAAC;;EAKN,MAAM,SAAS,OAAO,WAAW;AAEjC,MAAI,gBACF,OAAM,yBAAyB,WAAW,gBAAgB;MAE1D,SAAQ,KAAK,yBAAyB;AAGxC,SAAO;GACL,SACE,OAAO,SAAS,UAAU,UAAU,UAAU,SAAS,OAAO;GAChE,SAAS;GACT,SAAS;GACT,OAAO,OAAO;GACf;UACM,OAAO;EAEd,MAAM,eAAe,iBAAiB,QAAQ,MAAM,UAAU,OAAO,MAAM;EAC3E,MAAM,aAAa,iBAAiB,QAAQ,MAAM,QAAQ;AAE1D,QAAM,IAAI,MACR,8BAA8B,aAAa,gBAAgB,cAAc,6BAC1E"}
1
+ {"version":3,"file":"syncAppCatalog.mjs","names":[],"sources":["../../src/db/syncAppCatalog.ts"],"sourcesContent":["import type {\n GroupingTagDefinition,\n Resource,\n} from '../types/common/appCatalogTypes'\nimport { getDbClient } from './client'\nimport { TABLE_SYNC_MAGAZINE } from './tableSyncMagazine'\nimport { tableSyncPrisma } from './tableSyncPrismaAdapter'\nimport { readFile, readdir, stat } from 'node:fs/promises'\nimport { group } from 'radashi'\nimport { upsertAsset } from '../modules/assets/upsertAsset'\nimport type { ApprovalMethod, Group, Person } from '../types'\nimport type { PrismaClient } from '../generated/prisma/client'\nimport { naturalSort } from '../utils/naturalSort'\nimport { parseSourceSlug } from '../utils/parseSourceSlug'\n\nexport interface SyncAppCatalogResult {\n created: number\n updated: number\n deleted: number\n total: number\n}\n\ninterface AssetSyncResult {\n screenshotIds: string[]\n iconName: string | null\n}\n\nfunction isFileNotFoundError(error: unknown): boolean {\n return (\n error instanceof Error &&\n 'code' in error &&\n (error as NodeJS.ErrnoException).code === 'ENOENT'\n )\n}\n\nasync function processAssetDirectory(\n dirPath: string,\n appSlug: string,\n assetType: 'screenshot' | 'icon',\n prisma: PrismaClient,\n): Promise<string[]> {\n try {\n const files = await readdir(dirPath)\n const sortedFiles = naturalSort(files)\n const assetIds: string[] = []\n\n for (let i = 0; i < sortedFiles.length; i++) {\n const fileName = sortedFiles[i]\n if (!fileName) continue\n\n const assetName =\n assetType === 'screenshot'\n ? `${appSlug}-screenshot-${i + 1}`\n : `${appSlug}-icon`\n\n const id = await upsertAsset({\n prisma,\n buffer: await readFile(`${dirPath}/${fileName}`),\n originalFilename: fileName,\n name: assetName,\n assetType,\n })\n assetIds.push(id)\n\n // For icons, only process the first file\n if (assetType === 'icon') {\n break\n }\n }\n\n return assetIds\n } catch (error: unknown) {\n if (isFileNotFoundError(error)) {\n return []\n }\n throw error\n }\n}\n\nasync function syncAppAssets(\n appSlug: string,\n appPath: string,\n prisma: PrismaClient,\n): Promise<AssetSyncResult> {\n const screenshotIds = await processAssetDirectory(\n `${appPath}/screenshots`,\n appSlug,\n 'screenshot',\n prisma,\n )\n\n const iconIds = await processAssetDirectory(\n `${appPath}/icons`,\n appSlug,\n 'icon',\n prisma,\n )\n\n return {\n screenshotIds,\n iconName: iconIds.length > 0 ? `${appSlug}-icon` : null,\n }\n}\n\nasync function syncAssetsFromFileSystem(\n resources: Resource[],\n allAppsAssetsPath: string,\n) {\n const appDirectories = await readdir(allAppsAssetsPath)\n const prisma = getDbClient()\n const bySlug = group(resources, (a) => a.slug)\n\n for (const appDirName of appDirectories) {\n try {\n const stats = await stat(`${allAppsAssetsPath}/${appDirName}`)\n if (!stats.isDirectory()) {\n continue\n }\n } catch (error: unknown) {\n if (isFileNotFoundError(error)) {\n continue\n }\n throw error\n }\n\n const appSlug = appDirName\n if (!bySlug[appSlug]) {\n throw new Error(\n `App '${appSlug}' does not exist in the app catalog. Existing apps: ${Object.keys(bySlug).join(', ')}`,\n )\n }\n\n try {\n const { screenshotIds, iconName } = await syncAppAssets(\n appSlug,\n `${allAppsAssetsPath}/${appDirName}`,\n prisma,\n )\n\n const updateData: {\n screenshotIds?: string[]\n iconName?: string | null\n } = {}\n\n if (screenshotIds.length > 0) {\n updateData.screenshotIds = screenshotIds\n }\n if (iconName !== null) {\n updateData.iconName = iconName\n }\n\n if (Object.keys(updateData).length > 0) {\n await prisma.dbResource.update({\n where: { slug: appSlug },\n data: updateData,\n })\n }\n } catch (error: unknown) {\n const errorMessage =\n error instanceof Error ? error.message : String(error)\n throw new Error(\n `Error while upserting assets for app '${appSlug}': ${errorMessage}`,\n )\n }\n }\n}\n\n/**\n * Optional data to sync alongside the core app catalog.\n */\nexport interface SyncAppCatalogOptions {\n persons?: Person[]\n groups?: Group[]\n}\n\n/**\n * Syncs app catalog data to the database using table sync.\n * This will create new resources, update existing ones, and delete any that are no longer in the input.\n *\n * Note: Call connectDb() before and disconnectDb() after if running in a script.\n */\nexport async function syncAppCatalog(\n resources: Resource[],\n tagsDefinitions: GroupingTagDefinition[],\n approvalMethods: ApprovalMethod[],\n screenshotsPath?: string,\n options?: SyncAppCatalogOptions,\n): Promise<SyncAppCatalogResult> {\n try {\n const prisma = getDbClient()\n\n // Sync Persons first (groups depend on persons via memberships)\n if (options?.persons) {\n const dbPersons = options.persons.map((p) => ({\n slug: p.slug,\n firstName: p.firstName,\n lastName: p.lastName,\n email: p.email ?? null,\n }))\n await tableSyncPrisma({\n prisma,\n ...TABLE_SYNC_MAGAZINE.DbPerson,\n }).sync(dbPersons)\n }\n\n // Sync Groups (without memberships first)\n if (options?.groups) {\n const dbGroups = options.groups.map((g) => ({\n slug: g.slug,\n displayName: g.displayName ?? null,\n email: g.email ?? null,\n }))\n await tableSyncPrisma({\n prisma,\n ...TABLE_SYNC_MAGAZINE.DbGroup,\n }).sync(dbGroups)\n\n // Now sync GroupMemberships\n const allMemberships = options.groups.flatMap((g) =>\n g.memberSlugs.map((personSlug) => ({\n groupSlug: g.slug,\n personSlug,\n })),\n )\n await tableSyncPrisma({\n prisma,\n ...TABLE_SYNC_MAGAZINE.DbGroupMembership,\n }).sync(allMemberships)\n }\n\n await tableSyncPrisma({\n prisma,\n ...TABLE_SYNC_MAGAZINE.DbApprovalMethod,\n }).sync(approvalMethods)\n\n const sync = tableSyncPrisma({\n prisma,\n ...TABLE_SYNC_MAGAZINE.DbResource,\n })\n\n await tableSyncPrisma({\n prisma,\n ...TABLE_SYNC_MAGAZINE.DbAppTagDefinition,\n }).sync(tagsDefinitions)\n\n // Collect all unique source slugs for sync\n const uniqueSourceSlugs = new Set<string>()\n for (const resource of resources) {\n for (const source of resource.sources ?? []) {\n const url = typeof source === 'string' ? source : source.url\n const sourceSlug = parseSourceSlug(url)\n uniqueSourceSlugs.add(sourceSlug)\n }\n }\n\n // Sync Source entries using tableSyncPrisma\n const sources = Array.from(uniqueSourceSlugs).map((slug) => ({\n slug,\n userPrompt: null,\n }))\n await tableSyncPrisma({\n prisma,\n ...TABLE_SYNC_MAGAZINE.Source,\n }).sync(sources)\n\n // Sort resources: parents first (no parentSlug), then children — for FK integrity\n const sortedResources = [...resources].sort((a, b) => {\n const aIsChild = a.parentSlug ? 1 : 0\n const bIsChild = b.parentSlug ? 1 : 0\n return aIsChild - bIsChild\n })\n\n // Transform Resource to DbResource format (scalar fields only)\n const dbResources = sortedResources.map((resource) => {\n const slug =\n resource.slug ||\n resource.displayName\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n\n return {\n slug,\n type: resource.type ?? 'application',\n displayName: resource.displayName,\n abbreviation: resource.abbreviation ?? null,\n nicknames: resource.nicknames ?? [],\n description: resource.description,\n teams: resource.teams ?? [],\n accessRequest: resource.accessRequest ?? null,\n notes: resource.notes ?? null,\n tags: resource.tags ?? [],\n appUrl: resource.appUrl ?? null,\n links: resource.links ?? null,\n iconName: resource.iconName ?? null,\n screenshotIds: resource.screenshotIds ?? [],\n deprecated: resource.deprecated ?? null,\n aiPrompt: resource.aiPrompt ?? null,\n // aiMemory intentionally excluded from bulk sync — it is AI-owned and\n // preserved across restarts. Static-config values are seeded below (if DB is null).\n urlIssues: resource.urlIssues ?? [],\n tiers: resource.tiers ?? null,\n // Fields from former SubResource\n parentSlug: resource.parentSlug ?? null,\n tier: resource.tier ?? null,\n familySlug: resource.familySlug ?? null,\n aliases: resource.aliases ?? [],\n ownerPersonSlug: resource.ownerPersonSlug ?? null,\n accessMaintainerGroupSlugs: resource.accessMaintainerGroupSlugs ?? [],\n accessComments: resource.accessComments ?? null,\n extra: resource.extra ?? null,\n lastCheckedAt: resource.lastCheckedAt\n ? new Date(resource.lastCheckedAt)\n : null,\n nextCheckAfter: resource.nextCheckAfter\n ? new Date(resource.nextCheckAfter)\n : null,\n lastContentChangeAt: resource.lastContentChangeAt\n ? new Date(resource.lastContentChangeAt)\n : null,\n createdAt: resource.catalogAddedAt\n ? new Date(resource.catalogAddedAt)\n : undefined,\n }\n })\n\n // Sync resources\n const result = await sync.sync(dbResources)\n\n // Backfill catalogAddedAt → DB createdAt. Uses $executeRaw (not updateMany)\n // because Prisma's new client engine may treat @default(now()) fields as\n // server-managed and silently omit them from the SET clause. Raw SQL is the\n // only reliable path. The WHERE guard makes it idempotent: fires only when\n // the DB date is newer than catalogAddedAt (e.g. after a DB rebuild).\n for (const resource of resources) {\n if (!resource.catalogAddedAt) continue\n const addedAt = new Date(resource.catalogAddedAt)\n const slug =\n resource.slug ||\n resource.displayName\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n await prisma.$executeRaw`\n UPDATE \"DbResource\"\n SET \"createdAt\" = ${addedAt}\n WHERE \"slug\" = ${slug} AND \"createdAt\" > ${addedAt}\n `\n }\n\n // Resolve slug -> id for synced resources so SourceReference can reference by resourceId\n const slugs = dbResources.map((a) => a.slug)\n const resourceRows = await prisma.dbResource.findMany({\n where: { slug: { in: slugs } },\n select: { slug: true, id: true },\n })\n const slugToId = Object.fromEntries(resourceRows.map((r) => [r.slug, r.id]))\n\n // Build allSourceRefs with resourceId (slug already resolved to id)\n const allSourceRefs = sortedResources.flatMap((resource) => {\n const resourceSlug =\n resource.slug ||\n resource.displayName\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n const resourceId = slugToId[resourceSlug]\n if (!resourceId) {\n throw new Error(\n `Resource '${resourceSlug}' has no id after sync. Existing slugs: ${Object.keys(slugToId).join(', ')}`,\n )\n }\n\n return (resource.sources ?? []).map((source) => {\n const url = typeof source === 'string' ? source : source.url\n const sourceSlug = parseSourceSlug(url)\n return {\n resourceId,\n sourceSlug,\n url,\n parseDate: null,\n excerpts: [],\n userPrompt: null,\n }\n })\n })\n\n // Then sync all SourceReferences (with set semantics: old ones deleted, new ones created)\n await tableSyncPrisma({\n prisma,\n ...TABLE_SYNC_MAGAZINE.SourceReference,\n }).sync(allSourceRefs)\n\n // Seed aiMemory only for resources where the static config provides an initial value\n // and the DB currently has none. This preserves AI-written updates across restarts.\n const resourcesWithAiMemorySeed = sortedResources.filter(\n (r) => r.aiMemory != null && r.aiMemory.trim() !== '',\n )\n if (resourcesWithAiMemorySeed.length > 0) {\n for (const resource of resourcesWithAiMemorySeed) {\n const slug =\n resource.slug ||\n resource.displayName\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n await prisma.dbResource.updateMany({\n where: { slug, aiMemory: null },\n data: { aiMemory: resource.aiMemory },\n })\n }\n }\n\n // Get actual synced data to calculate stats\n const actual = result.getActual()\n\n if (screenshotsPath) {\n await syncAssetsFromFileSystem(resources, screenshotsPath)\n } else {\n console.warn('Do not sync screenhots')\n }\n\n return {\n created:\n actual.length - resources.length + (resources.length - actual.length),\n updated: 0, // TableSync doesn't expose this directly\n deleted: 0, // TableSync doesn't expose this directly\n total: actual.length,\n }\n } catch (error) {\n // Wrap error with context\n const errorMessage = error instanceof Error ? error.message : String(error)\n const errorStack = error instanceof Error ? error.stack : undefined\n\n throw new Error(\n `Error syncing app catalog: ${errorMessage}\\n\\nDetails:\\n${errorStack || 'No stack trace available'}`,\n )\n }\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@igstack/app-catalog-backend-core",
3
- "version": "0.18.2",
3
+ "version": "0.18.3",
4
4
  "description": "Backend core library for App Catalog",
5
5
  "homepage": "https://github.com/lislon/app-catalog",
6
6
  "repository": {
@@ -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.18.2",
49
- "@igstack/app-catalog-table-sync": "0.18.2"
48
+ "@igstack/app-catalog-shared-core": "0.18.3",
49
+ "@igstack/app-catalog-table-sync": "0.18.3"
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": "cefac0016ce6853c59936e4916fa870056e77e71",
76
+ "gitHead": "41db230ee5d2332cb80a30c7b54bbbeb5f2647aa",
77
77
  "scripts": {
78
78
  "build": "tsdown",
79
79
  "build:lenient": "tsdown",
@@ -327,27 +327,25 @@ export async function syncAppCatalog(
327
327
  // Sync resources
328
328
  const result = await sync.sync(dbResources)
329
329
 
330
- // Backfill catalogAddedAt → DB createdAt. The table-sync comparison treats
331
- // createdAt as just another field, but Prisma's update path may silently
332
- // drop it because it carries @default(now()). This dedicated pass uses
333
- // updateMany with a WHERE guard so it only fires when the DB date is newer
334
- // than the static catalogAddedAt (e.g. after a DB rebuild), and is a no-op
335
- // once the dates are already correct.
330
+ // Backfill catalogAddedAt → DB createdAt. Uses $executeRaw (not updateMany)
331
+ // because Prisma's new client engine may treat @default(now()) fields as
332
+ // server-managed and silently omit them from the SET clause. Raw SQL is the
333
+ // only reliable path. The WHERE guard makes it idempotent: fires only when
334
+ // the DB date is newer than catalogAddedAt (e.g. after a DB rebuild).
336
335
  for (const resource of resources) {
337
336
  if (!resource.catalogAddedAt) continue
338
337
  const addedAt = new Date(resource.catalogAddedAt)
339
- await prisma.dbResource.updateMany({
340
- where: {
341
- slug:
342
- resource.slug ||
343
- resource.displayName
344
- .toLowerCase()
345
- .replace(/[^a-z0-9]+/g, '-')
346
- .replace(/^-+|-+$/g, ''),
347
- createdAt: { gt: addedAt },
348
- },
349
- data: { createdAt: addedAt },
350
- })
338
+ const slug =
339
+ resource.slug ||
340
+ resource.displayName
341
+ .toLowerCase()
342
+ .replace(/[^a-z0-9]+/g, '-')
343
+ .replace(/^-+|-+$/g, '')
344
+ await prisma.$executeRaw`
345
+ UPDATE "DbResource"
346
+ SET "createdAt" = ${addedAt}
347
+ WHERE "slug" = ${slug} AND "createdAt" > ${addedAt}
348
+ `
351
349
  }
352
350
 
353
351
  // Resolve slug -> id for synced resources so SourceReference can reference by resourceId