@igstack/app-catalog-backend-core 0.15.0 → 0.17.0

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 (70) hide show
  1. package/dist/db/client.d.mts +54 -1
  2. package/dist/db/client.d.mts.map +1 -1
  3. package/dist/db/client.mjs +88 -7
  4. package/dist/db/client.mjs.map +1 -1
  5. package/dist/db/index.d.mts +1 -1
  6. package/dist/db/syncAppCatalog.mjs +2 -1
  7. package/dist/db/syncAppCatalog.mjs.map +1 -1
  8. package/dist/generated/prisma/internal/class.mjs +4 -4
  9. package/dist/generated/prisma/internal/class.mjs.map +1 -1
  10. package/dist/generated/prisma/internal/prismaNamespace.d.mts +1 -0
  11. package/dist/generated/prisma/internal/prismaNamespace.d.mts.map +1 -1
  12. package/dist/generated/prisma/models/DbResource.d.mts +43 -1
  13. package/dist/generated/prisma/models/DbResource.d.mts.map +1 -1
  14. package/dist/index.d.mts +2 -2
  15. package/dist/index.mjs +2 -2
  16. package/dist/middleware/database.d.mts +1 -0
  17. package/dist/middleware/database.d.mts.map +1 -1
  18. package/dist/middleware/database.mjs +10 -13
  19. package/dist/middleware/database.mjs.map +1 -1
  20. package/dist/modules/appCatalog/freshness.mjs +5 -3
  21. package/dist/modules/appCatalog/freshness.mjs.map +1 -1
  22. package/dist/modules/appCatalog/service.mjs +5 -1
  23. package/dist/modules/appCatalog/service.mjs.map +1 -1
  24. package/dist/modules/assets/assetCache.mjs +25 -0
  25. package/dist/modules/assets/assetCache.mjs.map +1 -0
  26. package/dist/modules/assets/assetRestController.d.mts.map +1 -1
  27. package/dist/modules/assets/assetRestController.mjs +17 -6
  28. package/dist/modules/assets/assetRestController.mjs.map +1 -1
  29. package/dist/modules/assets/assetUtils.mjs +1 -0
  30. package/dist/modules/assets/assetUtils.mjs.map +1 -1
  31. package/dist/modules/assets/screenshotRestController.d.mts.map +1 -1
  32. package/dist/modules/assets/screenshotRestController.mjs +7 -2
  33. package/dist/modules/assets/screenshotRestController.mjs.map +1 -1
  34. package/dist/modules/assets/upsertAsset.mjs +7 -1
  35. package/dist/modules/assets/upsertAsset.mjs.map +1 -1
  36. package/dist/modules/icons/iconRestController.d.mts.map +1 -1
  37. package/dist/modules/icons/iconRestController.mjs +7 -2
  38. package/dist/modules/icons/iconRestController.mjs.map +1 -1
  39. package/dist/modules/lighthouseKeeper/tools.d.mts.map +1 -1
  40. package/dist/modules/lighthouseKeeper/tools.mjs +5 -5
  41. package/dist/modules/lighthouseKeeper/tools.mjs.map +1 -1
  42. package/dist/types/common/appCatalogTypes.d.mts +8 -0
  43. package/dist/types/common/appCatalogTypes.d.mts.map +1 -1
  44. package/package.json +4 -4
  45. package/prisma/schema.prisma +5 -2
  46. package/src/__tests__/assetMime.test.ts +74 -0
  47. package/src/__tests__/dbSchema.test.ts +129 -0
  48. package/src/__tests__/dbSchemaAdapter.test.ts +118 -0
  49. package/src/__tests__/freshness.test.ts +56 -17
  50. package/src/__tests__/iconCache.test.ts +81 -0
  51. package/src/db/client.ts +118 -12
  52. package/src/db/index.ts +10 -1
  53. package/src/db/syncAppCatalog.ts +3 -0
  54. package/src/generated/prisma/internal/class.ts +4 -4
  55. package/src/generated/prisma/internal/prismaNamespace.ts +1 -0
  56. package/src/generated/prisma/internal/prismaNamespaceBrowser.ts +1 -0
  57. package/src/generated/prisma/models/DbResource.ts +44 -1
  58. package/src/index.ts +4 -0
  59. package/src/middleware/database.ts +20 -19
  60. package/src/modules/admin/chat/createDatabaseTools.ts +6 -2
  61. package/src/modules/appCatalog/freshness.ts +29 -11
  62. package/src/modules/appCatalog/service.ts +9 -4
  63. package/src/modules/assets/assetCache.ts +30 -0
  64. package/src/modules/assets/assetRestController.ts +25 -2
  65. package/src/modules/assets/assetUtils.ts +1 -0
  66. package/src/modules/assets/screenshotRestController.ts +13 -1
  67. package/src/modules/assets/upsertAsset.ts +8 -0
  68. package/src/modules/icons/iconRestController.ts +11 -1
  69. package/src/modules/lighthouseKeeper/tools.ts +6 -7
  70. package/src/types/common/appCatalogTypes.ts +8 -0
@@ -1,6 +1,59 @@
1
1
  import { PrismaClient } from "../generated/prisma/client.mjs";
2
+ import pg from "pg";
2
3
 
3
4
  //#region src/db/client.d.ts
5
+ type PrismaLogOption = NonNullable<ConstructorParameters<typeof PrismaClient>[0]>['log'];
6
+ /**
7
+ * The schema a deployment should be reading and writing.
8
+ *
9
+ * DB_SCHEMA is set per deployment, so it outranks a schema baked into the app
10
+ * config - otherwise a config that names `public` would pin a preview env back
11
+ * to the shared tables it was meant to be isolated from.
12
+ */
13
+ declare function resolveDbSchema(configuredSchema?: string): string | undefined;
14
+ /**
15
+ * `search_path` startup options for a schema-isolated deployment.
16
+ *
17
+ * node-postgres ignores Prisma's `?schema=` URL parameter, so a preview env's
18
+ * schema has to be injected as a connection option instead. This half covers
19
+ * raw SQL and the migrations; Prisma's own queries need the adapter half too,
20
+ * which is why every core connection goes through createCorePrismaClient.
21
+ */
22
+ declare function buildPgSchemaOptions(configuredSchema?: string): {
23
+ options?: string;
24
+ };
25
+ /**
26
+ * The one way to open a connection to the core database.
27
+ *
28
+ * Schema isolation takes two halves and both live here:
29
+ * - the pool's `search_path`, which raw SQL and the migrations follow;
30
+ * - the adapter's `schema`, which is what Prisma qualifies its table names
31
+ * with. A client built without it emits `"public"."DbResource"` however
32
+ * correct the pool's `current_schema()` is, so a preview env silently reads
33
+ * and overwrites the shared canonical tables.
34
+ *
35
+ * A pool built outside this factory gets neither half, so don't build one.
36
+ */
37
+ declare function createCorePrismaClient(params: {
38
+ connectionString: string;
39
+ configuredSchema?: string;
40
+ log?: PrismaLogOption;
41
+ }): {
42
+ client: PrismaClient;
43
+ pool: pg.Pool;
44
+ schema: string | undefined;
45
+ };
46
+ /**
47
+ * Fails a deployment that believes it is schema-isolated but is not.
48
+ *
49
+ * Speaks up whenever a schema was resolved at all - the same resolution feeds the
50
+ * pool's `search_path`, so the expectation and the connection can never disagree
51
+ * by construction, and a deployment that configures isolation without setting
52
+ * DB_SCHEMA is checked too. The case worth catching is a missing schema: Postgres
53
+ * silently skips a `search_path` entry that does not exist and falls through to
54
+ * `public`, which is the shared-catalog corruption this is here to prevent.
55
+ */
56
+ declare function verifyDbSchema(poolToCheck: pg.Pool, configuredSchema?: string): Promise<void>;
4
57
  /**
5
58
  * Gets the internal Prisma client instance.
6
59
  * Creates one if it doesn't exist.
@@ -22,5 +75,5 @@ declare function connectDb(): Promise<void>;
22
75
  */
23
76
  declare function disconnectDb(): Promise<void>;
24
77
  //#endregion
25
- export { connectDb, disconnectDb, getDbClient, setDbClient };
78
+ export { buildPgSchemaOptions, connectDb, createCorePrismaClient, disconnectDb, getDbClient, resolveDbSchema, setDbClient, verifyDbSchema };
26
79
  //# sourceMappingURL=client.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.mts","names":[],"sources":["../../src/db/client.ts"],"mappings":";;;;;AAYA;;iBAAgB,WAAA,CAAA,GAAe,YAAA;;;AA8B/B;;iBAAgB,WAAA,CAAY,MAAA,EAAQ,YAAA;;;AAQpC;;iBAAsB,SAAA,CAAA,GAAa,OAAA;;;AASnC;;iBAAsB,YAAA,CAAA,GAAgB,OAAA"}
1
+ {"version":3,"file":"client.d.mts","names":[],"sources":["../../src/db/client.ts"],"mappings":";;;;KAQK,eAAA,GAAkB,WAAA,CACrB,qBAAA,QAA6B,YAAA;;AAPZ;;;;;;iBAiBH,eAAA,CAAgB,gBAAA;;;;;;;AAAhC;;iBAYgB,oBAAA,CAAqB,gBAAA;EACnC,OAAA;AAAA;AADF;;;;;AAmBA;;;;;;;AAnBA,iBAmBgB,sBAAA,CAAuB,MAAA;EACrC,gBAAA;EACA,gBAAA;EACA,GAAA,GAAM,eAAA;AAAA;EACF,MAAA,EAAQ,YAAA;EAAc,IAAA,EAAM,EAAA,CAAG,IAAA;EAAM,MAAA;AAAA;;;;;;AA8C3C;;;;;iBAAsB,cAAA,CACpB,WAAA,EAAa,EAAA,CAAG,IAAA,EAChB,gBAAA,YACC,OAAA;;;;;iBAuBa,WAAA,CAAA,GAAe,YAAA;AAA/B;;;;AAAA,iBAqBgB,WAAA,CAAY,MAAA,EAAQ,YAAA;AAApC;;;;AAAA,iBAQsB,SAAA,CAAA,GAAa,OAAA;AAAnC;;;;AAAA,iBAUsB,YAAA,CAAA,GAAgB,OAAA"}
@@ -7,6 +7,89 @@ import pg from "pg";
7
7
  let prismaClient = null;
8
8
  let pool = null;
9
9
  /**
10
+ * The schema a deployment should be reading and writing.
11
+ *
12
+ * DB_SCHEMA is set per deployment, so it outranks a schema baked into the app
13
+ * config - otherwise a config that names `public` would pin a preview env back
14
+ * to the shared tables it was meant to be isolated from.
15
+ */
16
+ function resolveDbSchema(configuredSchema) {
17
+ return process.env.DB_SCHEMA || configuredSchema || void 0;
18
+ }
19
+ /**
20
+ * `search_path` startup options for a schema-isolated deployment.
21
+ *
22
+ * node-postgres ignores Prisma's `?schema=` URL parameter, so a preview env's
23
+ * schema has to be injected as a connection option instead. This half covers
24
+ * raw SQL and the migrations; Prisma's own queries need the adapter half too,
25
+ * which is why every core connection goes through createCorePrismaClient.
26
+ */
27
+ function buildPgSchemaOptions(configuredSchema) {
28
+ const schema = resolveDbSchema(configuredSchema);
29
+ return schema ? { options: `-c search_path=${schema}` } : {};
30
+ }
31
+ /**
32
+ * The one way to open a connection to the core database.
33
+ *
34
+ * Schema isolation takes two halves and both live here:
35
+ * - the pool's `search_path`, which raw SQL and the migrations follow;
36
+ * - the adapter's `schema`, which is what Prisma qualifies its table names
37
+ * with. A client built without it emits `"public"."DbResource"` however
38
+ * correct the pool's `current_schema()` is, so a preview env silently reads
39
+ * and overwrites the shared canonical tables.
40
+ *
41
+ * A pool built outside this factory gets neither half, so don't build one.
42
+ */
43
+ function createCorePrismaClient(params) {
44
+ const schema = resolveDbSchema(params.configuredSchema);
45
+ const ssl = buildPgSslConfig();
46
+ const newPool = new pg.Pool({
47
+ connectionString: params.connectionString,
48
+ ...ssl === void 0 ? {} : { ssl },
49
+ ...buildPgSchemaOptions(params.configuredSchema)
50
+ });
51
+ return {
52
+ client: new PrismaClient({
53
+ adapter: new PrismaPg(newPool, { schema }),
54
+ ...params.log === void 0 ? {} : { log: params.log }
55
+ }),
56
+ pool: newPool,
57
+ schema
58
+ };
59
+ }
60
+ /** Postgres NAMEDATALEN - 1: identifiers are truncated to this many bytes. */
61
+ const PG_MAX_IDENTIFIER_BYTES = 63;
62
+ /**
63
+ * How Postgres will have stored an identifier this long.
64
+ *
65
+ * A schema name over the limit is truncated identically wherever it appears - at
66
+ * CREATE SCHEMA and in `search_path` alike - so the isolation still holds and
67
+ * current_schema() merely reports the shortened name. Comparing raw strings would
68
+ * fail a correctly isolated deployment, so compare what Postgres kept.
69
+ */
70
+ function truncateIdentifier(name) {
71
+ const bytes = Buffer.from(name, "utf8");
72
+ if (bytes.length <= PG_MAX_IDENTIFIER_BYTES) return name;
73
+ return bytes.subarray(0, PG_MAX_IDENTIFIER_BYTES).toString("utf8");
74
+ }
75
+ /**
76
+ * Fails a deployment that believes it is schema-isolated but is not.
77
+ *
78
+ * Speaks up whenever a schema was resolved at all - the same resolution feeds the
79
+ * pool's `search_path`, so the expectation and the connection can never disagree
80
+ * by construction, and a deployment that configures isolation without setting
81
+ * DB_SCHEMA is checked too. The case worth catching is a missing schema: Postgres
82
+ * silently skips a `search_path` entry that does not exist and falls through to
83
+ * `public`, which is the shared-catalog corruption this is here to prevent.
84
+ */
85
+ async function verifyDbSchema(poolToCheck, configuredSchema) {
86
+ const resolved = resolveDbSchema(configuredSchema);
87
+ const actual = (await poolToCheck.query("SELECT current_schema() AS schema")).rows[0]?.schema ?? null;
88
+ console.log(`[db] core schema: resolved=${resolved ?? "(none)"} current_schema=${actual ?? "(none)"}`);
89
+ if (!resolved) return;
90
+ if (actual !== truncateIdentifier(resolved)) throw new Error(`The configured schema is "${resolved}" but the database resolved current_schema() to "${actual}". The schema is most likely missing, so every query would fall through to the shared catalog tables. Migrate this schema before starting the app.`);
91
+ }
92
+ /**
10
93
  * Gets the internal Prisma client instance.
11
94
  * Creates one if it doesn't exist.
12
95
  */
@@ -14,12 +97,9 @@ function getDbClient() {
14
97
  if (!prismaClient) {
15
98
  const databaseUrl = process.env.AC_CORE_DATABASE_URL;
16
99
  if (!databaseUrl) throw new Error("PrismaClient not initialized. You must call createAcMiddleware() before using database functions, or set AC_CORE_DATABASE_URL environment variable for standalone usage.");
17
- const ssl = buildPgSslConfig();
18
- pool = new pg.Pool({
19
- connectionString: databaseUrl,
20
- ...ssl === void 0 ? {} : { ssl }
21
- });
22
- prismaClient = new PrismaClient({ adapter: new PrismaPg(pool) });
100
+ const created = createCorePrismaClient({ connectionString: databaseUrl });
101
+ pool = created.pool;
102
+ prismaClient = created.client;
23
103
  }
24
104
  return prismaClient;
25
105
  }
@@ -36,6 +116,7 @@ function setDbClient(client) {
36
116
  */
37
117
  async function connectDb() {
38
118
  await getDbClient().$connect();
119
+ if (pool) await verifyDbSchema(pool);
39
120
  }
40
121
  /**
41
122
  * Disconnects from the database.
@@ -53,5 +134,5 @@ async function disconnectDb() {
53
134
  }
54
135
 
55
136
  //#endregion
56
- export { connectDb, disconnectDb, getDbClient, setDbClient };
137
+ export { buildPgSchemaOptions, connectDb, createCorePrismaClient, disconnectDb, getDbClient, resolveDbSchema, setDbClient, verifyDbSchema };
57
138
  //# sourceMappingURL=client.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"client.mjs","names":[],"sources":["../../src/db/client.ts"],"sourcesContent":["import { PrismaClient } from '../generated/prisma/client'\nimport { PrismaPg } from '@prisma/adapter-pg'\nimport pg from 'pg'\nimport { buildPgSslConfig } from './sslConfig'\n\nlet prismaClient: PrismaClient | null = null\nlet pool: pg.Pool | null = null\n\n/**\n * Gets the internal Prisma client instance.\n * Creates one if it doesn't exist.\n */\nexport function getDbClient(): PrismaClient {\n if (!prismaClient) {\n const databaseUrl = process.env.AC_CORE_DATABASE_URL\n if (!databaseUrl) {\n throw new Error(\n 'PrismaClient not initialized. You must call createAcMiddleware() before using database functions, ' +\n 'or set AC_CORE_DATABASE_URL environment variable for standalone usage.',\n )\n }\n\n // Prisma 7 with adapter: Create pg pool and wrap with adapter.\n // SSL comes from PGSSLMODE/PGSSLROOTCERT via our helper (node-postgres\n // doesn't honor those correctly for a connection-string pool see\n // buildPgSslConfig).\n const ssl = buildPgSslConfig()\n pool = new pg.Pool({\n connectionString: databaseUrl,\n ...(ssl === undefined ? {} : { ssl }),\n })\n const adapter = new PrismaPg(pool)\n\n prismaClient = new PrismaClient({ adapter })\n }\n return prismaClient\n}\n\n/**\n * Sets the internal Prisma client instance.\n * Used by middleware to bridge with existing getDbClient() usage.\n */\nexport function setDbClient(client: PrismaClient): void {\n prismaClient = client\n}\n\n/**\n * Connects to the database.\n * Call this before performing database operations.\n */\nexport async function connectDb(): Promise<void> {\n const client = getDbClient()\n await client.$connect()\n}\n\n/**\n * Disconnects from the database.\n * Call this when done with database operations (e.g., in scripts).\n */\nexport async function disconnectDb(): Promise<void> {\n if (prismaClient) {\n await prismaClient.$disconnect()\n prismaClient = null\n }\n if (pool) {\n await pool.end()\n pool = null\n }\n}\n"],"mappings":";;;;;;AAKA,IAAI,eAAoC;AACxC,IAAI,OAAuB;;;;;AAM3B,SAAgB,cAA4B;AAC1C,KAAI,CAAC,cAAc;EACjB,MAAM,cAAc,QAAQ,IAAI;AAChC,MAAI,CAAC,YACH,OAAM,IAAI,MACR,2KAED;EAOH,MAAM,MAAM,kBAAkB;AAC9B,SAAO,IAAI,GAAG,KAAK;GACjB,kBAAkB;GAClB,GAAI,QAAQ,SAAY,EAAE,GAAG,EAAE,KAAK;GACrC,CAAC;AAGF,iBAAe,IAAI,aAAa,EAAE,SAFlB,IAAI,SAAS,KAAK,EAES,CAAC;;AAE9C,QAAO;;;;;;AAOT,SAAgB,YAAY,QAA4B;AACtD,gBAAe;;;;;;AAOjB,eAAsB,YAA2B;AAE/C,OADe,aAAa,CACf,UAAU;;;;;;AAOzB,eAAsB,eAA8B;AAClD,KAAI,cAAc;AAChB,QAAM,aAAa,aAAa;AAChC,iBAAe;;AAEjB,KAAI,MAAM;AACR,QAAM,KAAK,KAAK;AAChB,SAAO"}
1
+ {"version":3,"file":"client.mjs","names":[],"sources":["../../src/db/client.ts"],"sourcesContent":["import { PrismaClient } from '../generated/prisma/client'\nimport { PrismaPg } from '@prisma/adapter-pg'\nimport pg from 'pg'\nimport { buildPgSslConfig } from './sslConfig'\n\nlet prismaClient: PrismaClient | null = null\nlet pool: pg.Pool | null = null\n\ntype PrismaLogOption = NonNullable<\n ConstructorParameters<typeof PrismaClient>[0]\n>['log']\n\n/**\n * The schema a deployment should be reading and writing.\n *\n * DB_SCHEMA is set per deployment, so it outranks a schema baked into the app\n * config - otherwise a config that names `public` would pin a preview env back\n * to the shared tables it was meant to be isolated from.\n */\nexport function resolveDbSchema(configuredSchema?: string): string | undefined {\n return process.env.DB_SCHEMA || configuredSchema || undefined\n}\n\n/**\n * `search_path` startup options for a schema-isolated deployment.\n *\n * node-postgres ignores Prisma's `?schema=` URL parameter, so a preview env's\n * schema has to be injected as a connection option instead. This half covers\n * raw SQL and the migrations; Prisma's own queries need the adapter half too,\n * which is why every core connection goes through createCorePrismaClient.\n */\nexport function buildPgSchemaOptions(configuredSchema?: string): {\n options?: string\n} {\n const schema = resolveDbSchema(configuredSchema)\n return schema ? { options: `-c search_path=${schema}` } : {}\n}\n\n/**\n * The one way to open a connection to the core database.\n *\n * Schema isolation takes two halves and both live here:\n * - the pool's `search_path`, which raw SQL and the migrations follow;\n * - the adapter's `schema`, which is what Prisma qualifies its table names\n * with. A client built without it emits `\"public\".\"DbResource\"` however\n * correct the pool's `current_schema()` is, so a preview env silently reads\n * and overwrites the shared canonical tables.\n *\n * A pool built outside this factory gets neither half, so don't build one.\n */\nexport function createCorePrismaClient(params: {\n connectionString: string\n configuredSchema?: string\n log?: PrismaLogOption\n}): { client: PrismaClient; pool: pg.Pool; schema: string | undefined } {\n const schema = resolveDbSchema(params.configuredSchema)\n // SSL comes from PGSSLMODE/PGSSLROOTCERT via our helper (node-postgres\n // doesn't honor those correctly for a connection-string pool - see\n // buildPgSslConfig).\n const ssl = buildPgSslConfig()\n const newPool = new pg.Pool({\n connectionString: params.connectionString,\n ...(ssl === undefined ? {} : { ssl }),\n ...buildPgSchemaOptions(params.configuredSchema),\n })\n const adapter = new PrismaPg(newPool, { schema })\n const client = new PrismaClient({\n adapter,\n ...(params.log === undefined ? {} : { log: params.log }),\n })\n return { client, pool: newPool, schema }\n}\n\n/** Postgres NAMEDATALEN - 1: identifiers are truncated to this many bytes. */\nconst PG_MAX_IDENTIFIER_BYTES = 63\n\n/**\n * How Postgres will have stored an identifier this long.\n *\n * A schema name over the limit is truncated identically wherever it appears - at\n * CREATE SCHEMA and in `search_path` alike - so the isolation still holds and\n * current_schema() merely reports the shortened name. Comparing raw strings would\n * fail a correctly isolated deployment, so compare what Postgres kept.\n */\nfunction truncateIdentifier(name: string): string {\n const bytes = Buffer.from(name, 'utf8')\n if (bytes.length <= PG_MAX_IDENTIFIER_BYTES) return name\n return bytes.subarray(0, PG_MAX_IDENTIFIER_BYTES).toString('utf8')\n}\n\n/**\n * Fails a deployment that believes it is schema-isolated but is not.\n *\n * Speaks up whenever a schema was resolved at all - the same resolution feeds the\n * pool's `search_path`, so the expectation and the connection can never disagree\n * by construction, and a deployment that configures isolation without setting\n * DB_SCHEMA is checked too. The case worth catching is a missing schema: Postgres\n * silently skips a `search_path` entry that does not exist and falls through to\n * `public`, which is the shared-catalog corruption this is here to prevent.\n */\nexport async function verifyDbSchema(\n poolToCheck: pg.Pool,\n configuredSchema?: string,\n): Promise<void> {\n const resolved = resolveDbSchema(configuredSchema)\n const result = await poolToCheck.query<{ schema: string | null }>(\n 'SELECT current_schema() AS schema',\n )\n const actual = result.rows[0]?.schema ?? null\n console.log(\n `[db] core schema: resolved=${resolved ?? '(none)'} current_schema=${actual ?? '(none)'}`,\n )\n if (!resolved) return\n if (actual !== truncateIdentifier(resolved)) {\n throw new Error(\n `The configured schema is \"${resolved}\" but the database resolved current_schema() to ` +\n `\"${actual}\". The schema is most likely missing, so every query would fall through to ` +\n `the shared catalog tables. Migrate this schema before starting the app.`,\n )\n }\n}\n\n/**\n * Gets the internal Prisma client instance.\n * Creates one if it doesn't exist.\n */\nexport function getDbClient(): PrismaClient {\n if (!prismaClient) {\n const databaseUrl = process.env.AC_CORE_DATABASE_URL\n if (!databaseUrl) {\n throw new Error(\n 'PrismaClient not initialized. You must call createAcMiddleware() before using database functions, ' +\n 'or set AC_CORE_DATABASE_URL environment variable for standalone usage.',\n )\n }\n\n const created = createCorePrismaClient({ connectionString: databaseUrl })\n pool = created.pool\n prismaClient = created.client\n }\n return prismaClient\n}\n\n/**\n * Sets the internal Prisma client instance.\n * Used by middleware to bridge with existing getDbClient() usage.\n */\nexport function setDbClient(client: PrismaClient): void {\n prismaClient = client\n}\n\n/**\n * Connects to the database.\n * Call this before performing database operations.\n */\nexport async function connectDb(): Promise<void> {\n const client = getDbClient()\n await client.$connect()\n if (pool) await verifyDbSchema(pool)\n}\n\n/**\n * Disconnects from the database.\n * Call this when done with database operations (e.g., in scripts).\n */\nexport async function disconnectDb(): Promise<void> {\n if (prismaClient) {\n await prismaClient.$disconnect()\n prismaClient = null\n }\n if (pool) {\n await pool.end()\n pool = null\n }\n}\n"],"mappings":";;;;;;AAKA,IAAI,eAAoC;AACxC,IAAI,OAAuB;;;;;;;;AAa3B,SAAgB,gBAAgB,kBAA+C;AAC7E,QAAO,QAAQ,IAAI,aAAa,oBAAoB;;;;;;;;;;AAWtD,SAAgB,qBAAqB,kBAEnC;CACA,MAAM,SAAS,gBAAgB,iBAAiB;AAChD,QAAO,SAAS,EAAE,SAAS,kBAAkB,UAAU,GAAG,EAAE;;;;;;;;;;;;;;AAe9D,SAAgB,uBAAuB,QAIiC;CACtE,MAAM,SAAS,gBAAgB,OAAO,iBAAiB;CAIvD,MAAM,MAAM,kBAAkB;CAC9B,MAAM,UAAU,IAAI,GAAG,KAAK;EAC1B,kBAAkB,OAAO;EACzB,GAAI,QAAQ,SAAY,EAAE,GAAG,EAAE,KAAK;EACpC,GAAG,qBAAqB,OAAO,iBAAiB;EACjD,CAAC;AAMF,QAAO;EAAE,QAJM,IAAI,aAAa;GAC9B,SAFc,IAAI,SAAS,SAAS,EAAE,QAAQ,CAAC;GAG/C,GAAI,OAAO,QAAQ,SAAY,EAAE,GAAG,EAAE,KAAK,OAAO,KAAK;GACxD,CAAC;EACe,MAAM;EAAS;EAAQ;;;AAI1C,MAAM,0BAA0B;;;;;;;;;AAUhC,SAAS,mBAAmB,MAAsB;CAChD,MAAM,QAAQ,OAAO,KAAK,MAAM,OAAO;AACvC,KAAI,MAAM,UAAU,wBAAyB,QAAO;AACpD,QAAO,MAAM,SAAS,GAAG,wBAAwB,CAAC,SAAS,OAAO;;;;;;;;;;;;AAapE,eAAsB,eACpB,aACA,kBACe;CACf,MAAM,WAAW,gBAAgB,iBAAiB;CAIlD,MAAM,UAHS,MAAM,YAAY,MAC/B,oCACD,EACqB,KAAK,IAAI,UAAU;AACzC,SAAQ,IACN,8BAA8B,YAAY,SAAS,kBAAkB,UAAU,WAChF;AACD,KAAI,CAAC,SAAU;AACf,KAAI,WAAW,mBAAmB,SAAS,CACzC,OAAM,IAAI,MACR,6BAA6B,SAAS,mDAChC,OAAO,oJAEd;;;;;;AAQL,SAAgB,cAA4B;AAC1C,KAAI,CAAC,cAAc;EACjB,MAAM,cAAc,QAAQ,IAAI;AAChC,MAAI,CAAC,YACH,OAAM,IAAI,MACR,2KAED;EAGH,MAAM,UAAU,uBAAuB,EAAE,kBAAkB,aAAa,CAAC;AACzE,SAAO,QAAQ;AACf,iBAAe,QAAQ;;AAEzB,QAAO;;;;;;AAOT,SAAgB,YAAY,QAA4B;AACtD,gBAAe;;;;;;AAOjB,eAAsB,YAA2B;AAE/C,OADe,aAAa,CACf,UAAU;AACvB,KAAI,KAAM,OAAM,eAAe,KAAK;;;;;;AAOtC,eAAsB,eAA8B;AAClD,KAAI,cAAc;AAChB,QAAM,aAAa,aAAa;AAChC,iBAAe;;AAEjB,KAAI,MAAM;AACR,QAAM,KAAK,KAAK;AAChB,SAAO"}
@@ -1,4 +1,4 @@
1
- import { connectDb, disconnectDb, getDbClient, setDbClient } from "./client.mjs";
1
+ import { buildPgSchemaOptions, connectDb, createCorePrismaClient, disconnectDb, getDbClient, resolveDbSchema, setDbClient, verifyDbSchema } from "./client.mjs";
2
2
  import { buildPgSslConfig } from "./sslConfig.mjs";
3
3
  import { MakeTFromPrismaModel, ObjectKeys, ScalarFilter, ScalarKeys, TableSyncParamsPrisma, tableSyncPrisma } from "./tableSyncPrismaAdapter.mjs";
4
4
  import { TABLE_SYNC_MAGAZINE, TableSyncMagazine, TableSyncMagazineModelNameKey } from "./tableSyncMagazine.mjs";
@@ -166,7 +166,8 @@ async function syncAppCatalog(resources, tagsDefinitions, approvalMethods, scree
166
166
  accessComments: resource.accessComments ?? null,
167
167
  extra: resource.extra ?? null,
168
168
  lastCheckedAt: resource.lastCheckedAt ? new Date(resource.lastCheckedAt) : null,
169
- nextCheckAfter: resource.nextCheckAfter ? new Date(resource.nextCheckAfter) : null
169
+ nextCheckAfter: resource.nextCheckAfter ? new Date(resource.nextCheckAfter) : null,
170
+ lastContentChangeAt: resource.lastContentChangeAt ? new Date(resource.lastContentChangeAt) : null
170
171
  };
171
172
  });
172
173
  const result = await sync.sync(dbResources);
@@ -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 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 }\n })\n\n // Sync resources\n const result = await sync.sync(dbResources)\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 // 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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"}
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 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 }\n })\n\n // Sync resources\n const result = await sync.sync(dbResources)\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 // 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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"}