@igstack/app-catalog-backend-core 0.14.1 → 0.16.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 (46) 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/index.d.mts +2 -2
  7. package/dist/index.mjs +2 -2
  8. package/dist/middleware/database.d.mts +1 -0
  9. package/dist/middleware/database.d.mts.map +1 -1
  10. package/dist/middleware/database.mjs +10 -13
  11. package/dist/middleware/database.mjs.map +1 -1
  12. package/dist/modules/assets/assetCache.mjs +25 -0
  13. package/dist/modules/assets/assetCache.mjs.map +1 -0
  14. package/dist/modules/assets/assetRestController.d.mts.map +1 -1
  15. package/dist/modules/assets/assetRestController.mjs +17 -6
  16. package/dist/modules/assets/assetRestController.mjs.map +1 -1
  17. package/dist/modules/assets/assetUtils.mjs +1 -0
  18. package/dist/modules/assets/assetUtils.mjs.map +1 -1
  19. package/dist/modules/assets/screenshotRestController.d.mts.map +1 -1
  20. package/dist/modules/assets/screenshotRestController.mjs +7 -2
  21. package/dist/modules/assets/screenshotRestController.mjs.map +1 -1
  22. package/dist/modules/assets/upsertAsset.mjs +7 -1
  23. package/dist/modules/assets/upsertAsset.mjs.map +1 -1
  24. package/dist/modules/icons/iconRestController.d.mts.map +1 -1
  25. package/dist/modules/icons/iconRestController.mjs +7 -2
  26. package/dist/modules/icons/iconRestController.mjs.map +1 -1
  27. package/dist/modules/lighthouseKeeper/tools.d.mts.map +1 -1
  28. package/dist/modules/lighthouseKeeper/tools.mjs +5 -5
  29. package/dist/modules/lighthouseKeeper/tools.mjs.map +1 -1
  30. package/package.json +4 -4
  31. package/src/__tests__/assetMime.test.ts +74 -0
  32. package/src/__tests__/dbSchema.test.ts +129 -0
  33. package/src/__tests__/dbSchemaAdapter.test.ts +118 -0
  34. package/src/__tests__/iconCache.test.ts +81 -0
  35. package/src/db/client.ts +118 -12
  36. package/src/db/index.ts +10 -1
  37. package/src/index.ts +4 -0
  38. package/src/middleware/database.ts +20 -19
  39. package/src/modules/admin/chat/createDatabaseTools.ts +6 -2
  40. package/src/modules/assets/assetCache.ts +30 -0
  41. package/src/modules/assets/assetRestController.ts +25 -2
  42. package/src/modules/assets/assetUtils.ts +1 -0
  43. package/src/modules/assets/screenshotRestController.ts +13 -1
  44. package/src/modules/assets/upsertAsset.ts +8 -0
  45. package/src/modules/icons/iconRestController.ts +11 -1
  46. package/src/modules/lighthouseKeeper/tools.ts +6 -7
@@ -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";
package/dist/index.d.mts CHANGED
@@ -23,7 +23,7 @@ import { AssetRestControllerConfig, registerAssetRestController } from "./module
23
23
  import { ScreenshotRestControllerConfig, registerScreenshotRestController } from "./modules/assets/screenshotRestController.mjs";
24
24
  import { SyncAssetsConfig, syncAssets } from "./modules/assets/syncAssets.mjs";
25
25
  import { checkAllLinks, printLinkCheckReport } from "./modules/appCatalog/checkLinks.mjs";
26
- import { connectDb, disconnectDb, getDbClient, setDbClient } from "./db/client.mjs";
26
+ import { buildPgSchemaOptions, connectDb, createCorePrismaClient, disconnectDb, getDbClient, resolveDbSchema, setDbClient, verifyDbSchema } from "./db/client.mjs";
27
27
  import { buildPgSslConfig } from "./db/sslConfig.mjs";
28
28
  import { MakeTFromPrismaModel, ObjectKeys, ScalarFilter, ScalarKeys, TableSyncParamsPrisma, tableSyncPrisma } from "./db/tableSyncPrismaAdapter.mjs";
29
29
  import { TABLE_SYNC_MAGAZINE, TableSyncMagazine, TableSyncMagazineModelNameKey } from "./db/tableSyncMagazine.mjs";
@@ -35,4 +35,4 @@ import { injectCustomScripts } from "./middleware/htmlInjection.mjs";
35
35
  import { runLighthouseKeeperDemo } from "./modules/lighthouseKeeper/demo.mjs";
36
36
  import { APP_CATALOG_AI_SYSTEM_PROMPT, createAppCatalogAITools } from "./modules/lighthouseKeeper/tools.mjs";
37
37
  import { getBuildPipelineId, getFrontendPackageVersion, getVersionInfo } from "./utils/versionUtils.mjs";
38
- export { APP_CATALOG_AI_SYSTEM_PROMPT, AcAppIndexed, AcAppPageIndexed, AcAppUiIndexed, AcAppsMeta, type AcAuthConfig, AcBackendAppDto, AcBackendAppInput, AcBackendAppUIBaseInput, AcBackendAppUIInput, AcBackendCredentialInput, AcBackendDataFreshness, AcBackendDataSourceInput, AcBackendDataSourceInputCommon, AcBackendDataSourceInputDb, AcBackendDataSourceInputKafka, AcBackendDataVersion, AcBackendDeployableInput, AcBackendDeployment, AcBackendDeploymentInput, AcBackendEnvironmentInput, AcBackendPageInput, type AcBackendProvider, AcBackendTagDescriptionDataIndexed, AcBackendTagFixedTagValue, AcBackendTagsDescriptionDataIndexed, AcBackendUiDefaultsInput, AcBackendVersionsRequestParams, AcBackendVersionsReturn, AcContextIndexed, type AcDatabaseConfig, AcDatabaseManager, AcEnvIndexed, type AcFeatureToggles, type AcLifecycleHooks, type AcLighthouseKeeperConfig, type AcMcpServerConfig, AcMetaDictionary, type AcMiddlewareOptions, type AcMiddlewareResult, AcResourceIndexed, type AcStaticControllerContract, type AcTrpcContext, type AcTrpcContextOptions, AccessRequest, AppAccessRequest, AppApprovalMethod, AppCatalogCompanySpecificBackend, AppCatalogData, AppCategory, AppRole, AppVersionInfo, ApprovalMethod, ApprovalMethodConfig, ApprovalMethodType, ApprovalUrl, type AssetRestControllerConfig, type AuthRouter, type BetterAuth, CustomConfig, Freshness, Group, GroupingTagDefinition, GroupingTagValue, type IconRestControllerConfig, type MakeTFromPrismaModel, type MiddlewareContext, type ObjectKeys, Person, PersonTeamConfig, type Resource, Role, type ScalarFilter, type ScalarKeys, type ScreenshotRestControllerConfig, ServiceConfig, SourceReference, type SyncAppCatalogResult, type SyncAssetsConfig, TABLE_SYNC_MAGAZINE, type TRPCRouter, type TableSyncMagazine, type TableSyncMagazineModelNameKey, type TableSyncParamsPrisma, Tag, TierVariant, type UpsertIconInput, VersionInfo, buildPgSslConfig, checkAllLinks, connectDb, createAcMiddleware, createAcTrpcContext, createAppCatalogAITools, createAuth, createAuthRouter, createTrpcRouter, disconnectDb, getAssetByName, getBuildPipelineId, getDbClient, getFrontendPackageVersion, getVersionInfo, injectCustomScripts, printLinkCheckReport, registerAssetRestController, registerAuthRoutes, registerIconRestController, registerScreenshotRestController, runLighthouseKeeperDemo, setDbClient, staticControllerContract, syncAppCatalog, syncAssets, tableSyncPrisma, upsertIcon, upsertIcons };
38
+ export { APP_CATALOG_AI_SYSTEM_PROMPT, AcAppIndexed, AcAppPageIndexed, AcAppUiIndexed, AcAppsMeta, type AcAuthConfig, AcBackendAppDto, AcBackendAppInput, AcBackendAppUIBaseInput, AcBackendAppUIInput, AcBackendCredentialInput, AcBackendDataFreshness, AcBackendDataSourceInput, AcBackendDataSourceInputCommon, AcBackendDataSourceInputDb, AcBackendDataSourceInputKafka, AcBackendDataVersion, AcBackendDeployableInput, AcBackendDeployment, AcBackendDeploymentInput, AcBackendEnvironmentInput, AcBackendPageInput, type AcBackendProvider, AcBackendTagDescriptionDataIndexed, AcBackendTagFixedTagValue, AcBackendTagsDescriptionDataIndexed, AcBackendUiDefaultsInput, AcBackendVersionsRequestParams, AcBackendVersionsReturn, AcContextIndexed, type AcDatabaseConfig, AcDatabaseManager, AcEnvIndexed, type AcFeatureToggles, type AcLifecycleHooks, type AcLighthouseKeeperConfig, type AcMcpServerConfig, AcMetaDictionary, type AcMiddlewareOptions, type AcMiddlewareResult, AcResourceIndexed, type AcStaticControllerContract, type AcTrpcContext, type AcTrpcContextOptions, AccessRequest, AppAccessRequest, AppApprovalMethod, AppCatalogCompanySpecificBackend, AppCatalogData, AppCategory, AppRole, AppVersionInfo, ApprovalMethod, ApprovalMethodConfig, ApprovalMethodType, ApprovalUrl, type AssetRestControllerConfig, type AuthRouter, type BetterAuth, CustomConfig, Freshness, Group, GroupingTagDefinition, GroupingTagValue, type IconRestControllerConfig, type MakeTFromPrismaModel, type MiddlewareContext, type ObjectKeys, Person, PersonTeamConfig, type Resource, Role, type ScalarFilter, type ScalarKeys, type ScreenshotRestControllerConfig, ServiceConfig, SourceReference, type SyncAppCatalogResult, type SyncAssetsConfig, TABLE_SYNC_MAGAZINE, type TRPCRouter, type TableSyncMagazine, type TableSyncMagazineModelNameKey, type TableSyncParamsPrisma, Tag, TierVariant, type UpsertIconInput, VersionInfo, buildPgSchemaOptions, buildPgSslConfig, checkAllLinks, connectDb, createAcMiddleware, createAcTrpcContext, createAppCatalogAITools, createAuth, createAuthRouter, createCorePrismaClient, createTrpcRouter, disconnectDb, getAssetByName, getBuildPipelineId, getDbClient, getFrontendPackageVersion, getVersionInfo, injectCustomScripts, printLinkCheckReport, registerAssetRestController, registerAuthRoutes, registerIconRestController, registerScreenshotRestController, resolveDbSchema, runLighthouseKeeperDemo, setDbClient, staticControllerContract, syncAppCatalog, syncAssets, tableSyncPrisma, upsertIcon, upsertIcons, verifyDbSchema };
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { buildPgSslConfig } from "./db/sslConfig.mjs";
2
- import { connectDb, disconnectDb, getDbClient, setDbClient } from "./db/client.mjs";
2
+ import { buildPgSchemaOptions, connectDb, createCorePrismaClient, disconnectDb, getDbClient, resolveDbSchema, setDbClient, verifyDbSchema } from "./db/client.mjs";
3
3
  import { createAuthRouter } from "./modules/auth/authRouter.mjs";
4
4
  import { createTrpcRouter } from "./server/controller.mjs";
5
5
  import { createAcTrpcContext } from "./server/acTrpcContext.mjs";
@@ -22,4 +22,4 @@ import { APP_CATALOG_AI_SYSTEM_PROMPT, createAppCatalogAITools } from "./modules
22
22
  import { runLighthouseKeeperDemo } from "./modules/lighthouseKeeper/demo.mjs";
23
23
  import { getBuildPipelineId, getFrontendPackageVersion, getVersionInfo } from "./utils/versionUtils.mjs";
24
24
 
25
- export { APP_CATALOG_AI_SYSTEM_PROMPT, AcDatabaseManager, TABLE_SYNC_MAGAZINE, buildPgSslConfig, checkAllLinks, connectDb, createAcMiddleware, createAcTrpcContext, createAppCatalogAITools, createAuth, createAuthRouter, createTrpcRouter, disconnectDb, getAssetByName, getBuildPipelineId, getDbClient, getFrontendPackageVersion, getVersionInfo, injectCustomScripts, printLinkCheckReport, registerAssetRestController, registerAuthRoutes, registerIconRestController, registerScreenshotRestController, runLighthouseKeeperDemo, setDbClient, staticControllerContract, syncAppCatalog, syncAssets, tableSyncPrisma, upsertIcon, upsertIcons };
25
+ export { APP_CATALOG_AI_SYSTEM_PROMPT, AcDatabaseManager, TABLE_SYNC_MAGAZINE, buildPgSchemaOptions, buildPgSslConfig, checkAllLinks, connectDb, createAcMiddleware, createAcTrpcContext, createAppCatalogAITools, createAuth, createAuthRouter, createCorePrismaClient, createTrpcRouter, disconnectDb, getAssetByName, getBuildPipelineId, getDbClient, getFrontendPackageVersion, getVersionInfo, injectCustomScripts, printLinkCheckReport, registerAssetRestController, registerAuthRoutes, registerIconRestController, registerScreenshotRestController, resolveDbSchema, runLighthouseKeeperDemo, setDbClient, staticControllerContract, syncAppCatalog, syncAssets, tableSyncPrisma, upsertIcon, upsertIcons, verifyDbSchema };
@@ -18,6 +18,7 @@ declare class AcDatabaseManager {
18
18
  getClient(): PrismaClient;
19
19
  connect(): Promise<void>;
20
20
  disconnect(): Promise<void>;
21
+ private configuredSchema;
21
22
  }
22
23
  //#endregion
23
24
  export { AcDatabaseManager };
@@ -1 +1 @@
1
- {"version":3,"file":"database.d.mts","names":[],"sources":["../../src/middleware/database.ts"],"mappings":";;;;;;AAuBA;;cAAa,iBAAA;EAAA,QACH,MAAA;EAAA,QACA,IAAA;EAAA,QACA,MAAA;cAEI,MAAA,EAAQ,gBAAA;EAyCO;;;;EAjC3B,SAAA,CAAA,GAAa,YAAA;EA4BP,OAAA,CAAA,GAAW,OAAA;EAKX,UAAA,CAAA,GAAc,OAAA;AAAA"}
1
+ {"version":3,"file":"database.d.mts","names":[],"sources":["../../src/middleware/database.ts"],"mappings":";;;;;;AAyBA;;cAAa,iBAAA;EAAA,QACH,MAAA;EAAA,QACA,IAAA;EAAA,QACA,MAAA;cAEI,MAAA,EAAQ,gBAAA;EAoCO;;;;EA5B3B,SAAA,CAAA,GAAa,YAAA;EAsBP,OAAA,CAAA,GAAW,OAAA;EAMX,UAAA,CAAA,GAAc,OAAA;EAAA,QAWZ,gBAAA;AAAA"}
@@ -1,8 +1,4 @@
1
- import { PrismaClient } from "../generated/prisma/client.mjs";
2
- import { buildPgSslConfig } from "../db/sslConfig.mjs";
3
- import { setDbClient } from "../db/client.mjs";
4
- import { PrismaPg } from "@prisma/adapter-pg";
5
- import pg from "pg";
1
+ import { createCorePrismaClient, setDbClient, verifyDbSchema } from "../db/client.mjs";
6
2
 
7
3
  //#region src/middleware/database.ts
8
4
  /**
@@ -29,22 +25,20 @@ var AcDatabaseManager = class {
29
25
  */
30
26
  getClient() {
31
27
  if (!this.client) {
32
- const datasourceUrl = formatConnectionUrl(this.config);
33
- const ssl = buildPgSslConfig();
34
- this.pool = new pg.Pool({
35
- connectionString: datasourceUrl,
36
- ...ssl === void 0 ? {} : { ssl }
37
- });
38
- this.client = new PrismaClient({
39
- adapter: new PrismaPg(this.pool),
28
+ const created = createCorePrismaClient({
29
+ connectionString: formatConnectionUrl(this.config),
30
+ configuredSchema: this.configuredSchema(),
40
31
  log: process.env.NODE_ENV === "development" ? ["warn", "error"] : ["warn", "error"]
41
32
  });
33
+ this.pool = created.pool;
34
+ this.client = created.client;
42
35
  setDbClient(this.client);
43
36
  }
44
37
  return this.client;
45
38
  }
46
39
  async connect() {
47
40
  await this.getClient().$connect();
41
+ if (this.pool) await verifyDbSchema(this.pool, this.configuredSchema());
48
42
  }
49
43
  async disconnect() {
50
44
  if (this.client) {
@@ -56,6 +50,9 @@ var AcDatabaseManager = class {
56
50
  this.pool = null;
57
51
  }
58
52
  }
53
+ configuredSchema() {
54
+ return "url" in this.config ? void 0 : this.config.schema;
55
+ }
59
56
  };
60
57
 
61
58
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"database.mjs","names":[],"sources":["../../src/middleware/database.ts"],"sourcesContent":["import { PrismaClient } from '../generated/prisma/client'\nimport { PrismaPg } from '@prisma/adapter-pg'\nimport pg from 'pg'\nimport type { AcDatabaseConfig } from './types'\nimport { setDbClient } from '../db/client'\nimport { buildPgSslConfig } from '../db/sslConfig'\n\n/**\n * Formats a database connection URL from structured config.\n */\nfunction formatConnectionUrl(config: AcDatabaseConfig): string {\n if ('url' in config) {\n return config.url\n }\n\n const { host, port, database, username, password, schema = 'public' } = config\n return `postgresql://${username}:${encodeURIComponent(password)}@${host}:${port}/${database}?schema=${schema}`\n}\n\n/**\n * Internal database manager used by the middleware.\n * Handles connection URL formatting and lifecycle.\n */\nexport class AcDatabaseManager {\n private client: PrismaClient | null = null\n private pool: pg.Pool | null = null\n private config: AcDatabaseConfig\n\n constructor(config: AcDatabaseConfig) {\n this.config = config\n }\n\n /**\n * Get or create the Prisma client instance.\n * Uses lazy initialization for flexibility.\n */\n getClient(): PrismaClient {\n if (!this.client) {\n const datasourceUrl = formatConnectionUrl(this.config)\n\n // Prisma 7 with adapter: Create pg pool and wrap with adapter.\n // SSL from PGSSLMODE/PGSSLROOTCERT via buildPgSslConfig (node-postgres\n // doesn't apply those correctly to a connection-string pool).\n const ssl = buildPgSslConfig()\n this.pool = new pg.Pool({\n connectionString: datasourceUrl,\n ...(ssl === undefined ? {} : { ssl }),\n })\n const adapter = new PrismaPg(this.pool)\n\n this.client = new PrismaClient({\n adapter,\n log:\n process.env.NODE_ENV === 'development'\n ? ['warn', 'error']\n : ['warn', 'error'],\n })\n\n // Bridge with existing backend-core getDbClient() usage\n setDbClient(this.client)\n }\n return this.client\n }\n\n async connect(): Promise<void> {\n const client = this.getClient()\n await client.$connect()\n }\n\n async disconnect(): Promise<void> {\n if (this.client) {\n await this.client.$disconnect()\n this.client = null\n }\n if (this.pool) {\n await this.pool.end()\n this.pool = null\n }\n }\n}\n"],"mappings":";;;;;;;;;;AAUA,SAAS,oBAAoB,QAAkC;AAC7D,KAAI,SAAS,OACX,QAAO,OAAO;CAGhB,MAAM,EAAE,MAAM,MAAM,UAAU,UAAU,UAAU,SAAS,aAAa;AACxE,QAAO,gBAAgB,SAAS,GAAG,mBAAmB,SAAS,CAAC,GAAG,KAAK,GAAG,KAAK,GAAG,SAAS,UAAU;;;;;;AAOxG,IAAa,oBAAb,MAA+B;CAK7B,YAAY,QAA0B;gBAJA;cACP;AAI7B,OAAK,SAAS;;;;;;CAOhB,YAA0B;AACxB,MAAI,CAAC,KAAK,QAAQ;GAChB,MAAM,gBAAgB,oBAAoB,KAAK,OAAO;GAKtD,MAAM,MAAM,kBAAkB;AAC9B,QAAK,OAAO,IAAI,GAAG,KAAK;IACtB,kBAAkB;IAClB,GAAI,QAAQ,SAAY,EAAE,GAAG,EAAE,KAAK;IACrC,CAAC;AAGF,QAAK,SAAS,IAAI,aAAa;IAC7B,SAHc,IAAI,SAAS,KAAK,KAAK;IAIrC,KACE,QAAQ,IAAI,aAAa,gBACrB,CAAC,QAAQ,QAAQ,GACjB,CAAC,QAAQ,QAAQ;IACxB,CAAC;AAGF,eAAY,KAAK,OAAO;;AAE1B,SAAO,KAAK;;CAGd,MAAM,UAAyB;AAE7B,QADe,KAAK,WAAW,CAClB,UAAU;;CAGzB,MAAM,aAA4B;AAChC,MAAI,KAAK,QAAQ;AACf,SAAM,KAAK,OAAO,aAAa;AAC/B,QAAK,SAAS;;AAEhB,MAAI,KAAK,MAAM;AACb,SAAM,KAAK,KAAK,KAAK;AACrB,QAAK,OAAO"}
1
+ {"version":3,"file":"database.mjs","names":[],"sources":["../../src/middleware/database.ts"],"sourcesContent":["import type { PrismaClient } from '../generated/prisma/client'\nimport type pg from 'pg'\nimport type { AcDatabaseConfig } from './types'\nimport {\n createCorePrismaClient,\n setDbClient,\n verifyDbSchema,\n} from '../db/client'\n\n/**\n * Formats a database connection URL from structured config.\n */\nfunction formatConnectionUrl(config: AcDatabaseConfig): string {\n if ('url' in config) {\n return config.url\n }\n\n const { host, port, database, username, password, schema = 'public' } = config\n return `postgresql://${username}:${encodeURIComponent(password)}@${host}:${port}/${database}?schema=${schema}`\n}\n\n/**\n * Internal database manager used by the middleware.\n * Handles connection URL formatting and lifecycle.\n */\nexport class AcDatabaseManager {\n private client: PrismaClient | null = null\n private pool: pg.Pool | null = null\n private config: AcDatabaseConfig\n\n constructor(config: AcDatabaseConfig) {\n this.config = config\n }\n\n /**\n * Get or create the Prisma client instance.\n * Uses lazy initialization for flexibility.\n */\n getClient(): PrismaClient {\n if (!this.client) {\n // This client - not getDbClient()'s - is the one the app ends up using.\n // The `?schema=` in the url is inert (node-postgres drops unknown URL\n // params), so the schema has to travel via createCorePrismaClient.\n const created = createCorePrismaClient({\n connectionString: formatConnectionUrl(this.config),\n configuredSchema: this.configuredSchema(),\n log:\n process.env.NODE_ENV === 'development'\n ? ['warn', 'error']\n : ['warn', 'error'],\n })\n this.pool = created.pool\n this.client = created.client\n\n // Bridge with existing backend-core getDbClient() usage\n setDbClient(this.client)\n }\n return this.client\n }\n\n async connect(): Promise<void> {\n const client = this.getClient()\n await client.$connect()\n if (this.pool) await verifyDbSchema(this.pool, this.configuredSchema())\n }\n\n async disconnect(): Promise<void> {\n if (this.client) {\n await this.client.$disconnect()\n this.client = null\n }\n if (this.pool) {\n await this.pool.end()\n this.pool = null\n }\n }\n\n private configuredSchema(): string | undefined {\n return 'url' in this.config ? undefined : this.config.schema\n }\n}\n"],"mappings":";;;;;;AAYA,SAAS,oBAAoB,QAAkC;AAC7D,KAAI,SAAS,OACX,QAAO,OAAO;CAGhB,MAAM,EAAE,MAAM,MAAM,UAAU,UAAU,UAAU,SAAS,aAAa;AACxE,QAAO,gBAAgB,SAAS,GAAG,mBAAmB,SAAS,CAAC,GAAG,KAAK,GAAG,KAAK,GAAG,SAAS,UAAU;;;;;;AAOxG,IAAa,oBAAb,MAA+B;CAK7B,YAAY,QAA0B;gBAJA;cACP;AAI7B,OAAK,SAAS;;;;;;CAOhB,YAA0B;AACxB,MAAI,CAAC,KAAK,QAAQ;GAIhB,MAAM,UAAU,uBAAuB;IACrC,kBAAkB,oBAAoB,KAAK,OAAO;IAClD,kBAAkB,KAAK,kBAAkB;IACzC,KACE,QAAQ,IAAI,aAAa,gBACrB,CAAC,QAAQ,QAAQ,GACjB,CAAC,QAAQ,QAAQ;IACxB,CAAC;AACF,QAAK,OAAO,QAAQ;AACpB,QAAK,SAAS,QAAQ;AAGtB,eAAY,KAAK,OAAO;;AAE1B,SAAO,KAAK;;CAGd,MAAM,UAAyB;AAE7B,QADe,KAAK,WAAW,CAClB,UAAU;AACvB,MAAI,KAAK,KAAM,OAAM,eAAe,KAAK,MAAM,KAAK,kBAAkB,CAAC;;CAGzE,MAAM,aAA4B;AAChC,MAAI,KAAK,QAAQ;AACf,SAAM,KAAK,OAAO,aAAa;AAC/B,QAAK,SAAS;;AAEhB,MAAI,KAAK,MAAM;AACb,SAAM,KAAK,KAAK,KAAK;AACrB,QAAK,OAAO;;;CAIhB,AAAQ,mBAAuC;AAC7C,SAAO,SAAS,KAAK,SAAS,SAAY,KAAK,OAAO"}
@@ -0,0 +1,25 @@
1
+ //#region src/modules/assets/assetCache.ts
2
+ /**
3
+ * Asset URLs are keyed by row id or by icon name, and `upsertAsset` replaces
4
+ * content in place rather than inserting a new row — so the bytes behind a
5
+ * given URL can change. A long `max-age` would then serve the old artwork for
6
+ * the whole window (an icon replacement stayed invisible for a day). Revalidate
7
+ * against the stored checksum instead: unchanged content still costs only a
8
+ * conditional request answered with 304, and a replacement shows up at once.
9
+ *
10
+ * `variant` distinguishes derived renditions of the same row (a resize width,
11
+ * for example) so they do not share one entity tag.
12
+ */
13
+ function setRevalidatingCacheHeaders(res, checksum, variant) {
14
+ const etag = variant === void 0 ? `"${checksum}"` : `"${checksum}-${variant}"`;
15
+ res.setHeader("ETag", etag);
16
+ res.setHeader("Cache-Control", "public, max-age=0, must-revalidate");
17
+ return etag;
18
+ }
19
+ function isNotModified(req, etag) {
20
+ return req.headers["if-none-match"] === etag;
21
+ }
22
+
23
+ //#endregion
24
+ export { isNotModified, setRevalidatingCacheHeaders };
25
+ //# sourceMappingURL=assetCache.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"assetCache.mjs","names":[],"sources":["../../../src/modules/assets/assetCache.ts"],"sourcesContent":["import type { Request, Response } from 'express'\n\n/**\n * Asset URLs are keyed by row id or by icon name, and `upsertAsset` replaces\n * content in place rather than inserting a new row — so the bytes behind a\n * given URL can change. A long `max-age` would then serve the old artwork for\n * the whole window (an icon replacement stayed invisible for a day). Revalidate\n * against the stored checksum instead: unchanged content still costs only a\n * conditional request answered with 304, and a replacement shows up at once.\n *\n * `variant` distinguishes derived renditions of the same row (a resize width,\n * for example) so they do not share one entity tag.\n */\nexport function setRevalidatingCacheHeaders(\n res: Response,\n checksum: string,\n variant?: string | number,\n): string {\n const etag =\n variant === undefined ? `\"${checksum}\"` : `\"${checksum}-${variant}\"`\n\n res.setHeader('ETag', etag)\n res.setHeader('Cache-Control', 'public, max-age=0, must-revalidate')\n\n return etag\n}\n\nexport function isNotModified(req: Request, etag: string): boolean {\n return req.headers['if-none-match'] === etag\n}\n"],"mappings":";;;;;;;;;;;;AAaA,SAAgB,4BACd,KACA,UACA,SACQ;CACR,MAAM,OACJ,YAAY,SAAY,IAAI,SAAS,KAAK,IAAI,SAAS,GAAG,QAAQ;AAEpE,KAAI,UAAU,QAAQ,KAAK;AAC3B,KAAI,UAAU,iBAAiB,qCAAqC;AAEpE,QAAO;;AAGT,SAAgB,cAAc,KAAc,MAAuB;AACjE,QAAO,IAAI,QAAQ,qBAAqB"}
@@ -1 +1 @@
1
- {"version":3,"file":"assetRestController.d.mts","names":[],"sources":["../../../src/modules/assets/assetRestController.ts"],"mappings":";;;UAuBiB,yBAAA;;AAAjB;;EAIE,QAAA;AAAA;;;;;;;;;;iBA0Bc,2BAAA,CACd,MAAA,EAAQ,MAAA,EACR,MAAA,EAAQ,yBAAA"}
1
+ {"version":3,"file":"assetRestController.d.mts","names":[],"sources":["../../../src/modules/assets/assetRestController.ts"],"mappings":";;;UAwBiB,yBAAA;;AAAjB;;EAIE,QAAA;AAAA;;;;;;;;;;iBA0Bc,2BAAA,CACd,MAAA,EAAQ,MAAA,EACR,MAAA,EAAQ,yBAAA"}
@@ -1,6 +1,7 @@
1
1
  import { getDbClient } from "../../db/client.mjs";
2
2
  import { getImageFormat, isRasterImage, resizeImage } from "./assetUtils.mjs";
3
3
  import { upsertAsset } from "./upsertAsset.mjs";
4
+ import { isNotModified, setRevalidatingCacheHeaders } from "./assetCache.mjs";
4
5
  import sharp from "sharp";
5
6
  import multer from "multer";
6
7
 
@@ -63,7 +64,8 @@ function registerAssetRestController(router, config) {
63
64
  mimeType: true,
64
65
  name: true,
65
66
  width: true,
66
- height: true
67
+ height: true,
68
+ checksum: true
67
69
  }
68
70
  });
69
71
  if (!asset) {
@@ -75,7 +77,12 @@ function registerAssetRestController(router, config) {
75
77
  const width = wParam ? Number.parseInt(wParam, 10) : void 0;
76
78
  let outBuffer = asset.content;
77
79
  let outMime = asset.mimeType;
78
- if (resizeEnabled && isRasterImage(asset.mimeType) && !!width && Number.isFinite(width) && width > 0) {
80
+ const shouldResize = resizeEnabled && isRasterImage(asset.mimeType) && !!width && Number.isFinite(width) && width > 0;
81
+ if (isNotModified(req, setRevalidatingCacheHeaders(res, asset.checksum, shouldResize ? `w${width}` : void 0))) {
82
+ res.status(304).end();
83
+ return;
84
+ }
85
+ if (shouldResize) {
79
86
  const fmt = getImageFormat(asset.mimeType) || "jpeg";
80
87
  const buf = await resizeImage(Buffer.from(asset.content), width, void 0, fmt);
81
88
  outBuffer = new Uint8Array(buf);
@@ -83,7 +90,6 @@ function registerAssetRestController(router, config) {
83
90
  }
84
91
  res.setHeader("Content-Type", outMime);
85
92
  res.setHeader("Content-Disposition", `inline; filename="${asset.name}"`);
86
- res.setHeader("Cache-Control", "public, max-age=86400");
87
93
  res.send(outBuffer);
88
94
  } catch (error) {
89
95
  console.error("Error fetching asset:", error);
@@ -127,7 +133,8 @@ function registerAssetRestController(router, config) {
127
133
  mimeType: true,
128
134
  name: true,
129
135
  width: true,
130
- height: true
136
+ height: true,
137
+ checksum: true
131
138
  }
132
139
  });
133
140
  if (!asset) {
@@ -140,7 +147,12 @@ function registerAssetRestController(router, config) {
140
147
  let outBuffer = asset.content;
141
148
  let outMime = asset.mimeType;
142
149
  const isRaster = asset.mimeType.startsWith("image/") && !asset.mimeType.includes("svg");
143
- if (resizeEnabled && isRaster && !!width && Number.isFinite(width) && width > 0) {
150
+ const shouldResize = resizeEnabled && isRaster && !!width && Number.isFinite(width) && width > 0;
151
+ if (isNotModified(req, setRevalidatingCacheHeaders(res, asset.checksum, shouldResize ? `w${width}` : void 0))) {
152
+ res.status(304).end();
153
+ return;
154
+ }
155
+ if (shouldResize) {
144
156
  const fmt = asset.mimeType.includes("png") ? "png" : asset.mimeType.includes("webp") ? "webp" : "jpeg";
145
157
  let buf;
146
158
  const pipeline = sharp(Buffer.from(asset.content)).resize({
@@ -162,7 +174,6 @@ function registerAssetRestController(router, config) {
162
174
  }
163
175
  res.setHeader("Content-Type", outMime);
164
176
  res.setHeader("Content-Disposition", `inline; filename="${asset.name}"`);
165
- res.setHeader("Cache-Control", "public, max-age=86400");
166
177
  res.send(outBuffer);
167
178
  } catch (error) {
168
179
  console.error("Error fetching asset by name:", error);
@@ -1 +1 @@
1
- {"version":3,"file":"assetRestController.mjs","names":[],"sources":["../../../src/modules/assets/assetRestController.ts"],"sourcesContent":["import type { Request, Response, Router } from 'express'\nimport multer from 'multer'\nimport sharp from 'sharp'\nimport { getDbClient } from '../../db'\nimport { getImageFormat, isRasterImage, resizeImage } from './assetUtils'\nimport { upsertAsset } from './upsertAsset'\n\n// Configure multer for memory storage\nconst upload = multer({\n storage: multer.memoryStorage(),\n limits: {\n fileSize: 10 * 1024 * 1024, // 10MB limit\n },\n fileFilter: (_req, file, cb) => {\n // Accept images only\n if (!file.mimetype.startsWith('image/')) {\n cb(new Error('Only image files are allowed'))\n return\n }\n cb(null, true)\n },\n})\n\nexport interface AssetRestControllerConfig {\n /**\n * Base path for asset endpoints (e.g., '/api/assets')\n */\n basePath: string\n}\n\nexport interface ParseAssetParams {\n buffer: Buffer\n originalFilename: string\n fileSize?: number\n}\n\nexport interface ParseAssetReturn {\n checksum: string\n fileSize: number\n mimeType: string\n width?: number\n height?: number\n}\n\n/**\n * Registers REST endpoints for universal asset upload and retrieval\n *\n * Endpoints:\n * - POST {basePath}/upload - Upload a new asset (multipart/form-data)\n * - GET {basePath}/:id - Get asset binary by ID\n * - GET {basePath}/:id/metadata - Get asset metadata only\n * - GET {basePath}/by-name/:name - Get asset binary by name\n */\nexport function registerAssetRestController(\n router: Router,\n config: AssetRestControllerConfig,\n): void {\n const { basePath } = config\n const prisma = getDbClient()\n\n // Upload endpoint - accepts multipart/form-data\n router.post(\n `${basePath}/upload`,\n upload.single('asset'),\n async (req: Request, res: Response) => {\n try {\n if (!req.file) {\n res.status(400).json({ error: 'No file uploaded' })\n return\n }\n\n const name = req.body['name'] as string\n const assetType = req.body['assetType']\n\n if (!name) {\n res.status(400).json({ error: 'Name is required' })\n return\n }\n\n const id = await upsertAsset({\n prisma,\n buffer: req.file.buffer,\n name,\n originalFilename: req.file.filename,\n assetType,\n })\n\n res.status(201).json({ id })\n } catch (error) {\n console.error('Error uploading asset:', error)\n res.status(500).json({ error: 'Failed to upload asset' })\n }\n },\n )\n\n // Get asset binary by ID\n router.get(`${basePath}/:id`, async (req: Request, res: Response) => {\n try {\n const { id } = req.params\n\n const asset = await prisma.dbAsset.findUnique({\n where: { id },\n select: {\n content: true,\n mimeType: true,\n name: true,\n width: true,\n height: true,\n },\n })\n\n if (!asset) {\n res.status(404).json({ error: 'Asset not found' })\n return\n }\n\n const resizeEnabled =\n String(process.env.EH_ASSETS_RESIZE_ENABLED || 'true') === 'true'\n const wParam = req.query['w'] as string | undefined\n const width = wParam ? Number.parseInt(wParam, 10) : undefined\n\n let outBuffer: Uint8Array = asset.content\n let outMime = asset.mimeType\n\n const shouldResize =\n resizeEnabled &&\n isRasterImage(asset.mimeType) &&\n !!width &&\n Number.isFinite(width) &&\n width > 0\n\n if (shouldResize) {\n const fmt = getImageFormat(asset.mimeType) || 'jpeg'\n const buf = await resizeImage(\n Buffer.from(asset.content),\n width,\n undefined,\n fmt,\n )\n outBuffer = new Uint8Array(buf)\n outMime = `image/${fmt}`\n }\n\n // Set appropriate headers\n res.setHeader('Content-Type', outMime)\n res.setHeader('Content-Disposition', `inline; filename=\"${asset.name}\"`)\n res.setHeader('Cache-Control', 'public, max-age=86400') // Cache for 1 day\n\n // Send binary content (resized if requested)\n res.send(outBuffer)\n } catch (error) {\n console.error('Error fetching asset:', error)\n res.status(500).json({ error: 'Failed to fetch asset' })\n }\n })\n\n // Get asset metadata only (no binary content)\n router.get(\n `${basePath}/:id/metadata`,\n async (req: Request, res: Response) => {\n try {\n const { id } = req.params\n\n const asset = await prisma.dbAsset.findUnique({\n where: { id },\n select: {\n id: true,\n name: true,\n assetType: true,\n mimeType: true,\n fileSize: true,\n width: true,\n height: true,\n createdAt: true,\n updatedAt: true,\n },\n })\n\n if (!asset) {\n res.status(404).json({ error: 'Asset not found' })\n return\n }\n\n res.json(asset)\n } catch (error) {\n console.error('Error fetching asset metadata:', error)\n res.status(500).json({ error: 'Failed to fetch asset metadata' })\n }\n },\n )\n\n // Get asset binary by name\n router.get(\n `${basePath}/by-name/:name`,\n async (req: Request, res: Response) => {\n try {\n const { name } = req.params\n\n const asset = await prisma.dbAsset.findUnique({\n where: { name },\n select: {\n content: true,\n mimeType: true,\n name: true,\n width: true,\n height: true,\n },\n })\n\n if (!asset) {\n res.status(404).json({ error: 'Asset not found' })\n return\n }\n\n const resizeEnabled =\n String(process.env.EH_ASSETS_RESIZE_ENABLED || 'true') === 'true'\n const wParam = req.query['w'] as string | undefined\n const width = wParam ? Number.parseInt(wParam, 10) : undefined\n\n let outBuffer: Uint8Array = asset.content\n let outMime = asset.mimeType\n\n const isRaster =\n asset.mimeType.startsWith('image/') && !asset.mimeType.includes('svg')\n const shouldResize =\n resizeEnabled &&\n isRaster &&\n !!width &&\n Number.isFinite(width) &&\n width > 0\n\n if (shouldResize) {\n const fmt = asset.mimeType.includes('png')\n ? 'png'\n : asset.mimeType.includes('webp')\n ? 'webp'\n : 'jpeg'\n\n let buf: Buffer\n const pipeline = sharp(Buffer.from(asset.content)).resize({\n width,\n fit: 'inside',\n withoutEnlargement: true,\n })\n if (fmt === 'png') {\n buf = await pipeline.png().toBuffer()\n outMime = 'image/png'\n } else if (fmt === 'webp') {\n buf = await pipeline.webp().toBuffer()\n outMime = 'image/webp'\n } else {\n buf = await pipeline.jpeg().toBuffer()\n outMime = 'image/jpeg'\n }\n outBuffer = new Uint8Array(buf)\n }\n\n // Set appropriate headers\n res.setHeader('Content-Type', outMime)\n res.setHeader('Content-Disposition', `inline; filename=\"${asset.name}\"`)\n res.setHeader('Cache-Control', 'public, max-age=86400') // Cache for 1 day\n\n // Send binary content (resized if requested)\n res.send(outBuffer)\n } catch (error) {\n console.error('Error fetching asset by name:', error)\n res.status(500).json({ error: 'Failed to fetch asset' })\n }\n },\n )\n}\n"],"mappings":";;;;;;;AAQA,MAAM,SAAS,OAAO;CACpB,SAAS,OAAO,eAAe;CAC/B,QAAQ,EACN,UAAU,KAAK,OAAO,MACvB;CACD,aAAa,MAAM,MAAM,OAAO;AAE9B,MAAI,CAAC,KAAK,SAAS,WAAW,SAAS,EAAE;AACvC,sBAAG,IAAI,MAAM,+BAA+B,CAAC;AAC7C;;AAEF,KAAG,MAAM,KAAK;;CAEjB,CAAC;;;;;;;;;;AAgCF,SAAgB,4BACd,QACA,QACM;CACN,MAAM,EAAE,aAAa;CACrB,MAAM,SAAS,aAAa;AAG5B,QAAO,KACL,GAAG,SAAS,UACZ,OAAO,OAAO,QAAQ,EACtB,OAAO,KAAc,QAAkB;AACrC,MAAI;AACF,OAAI,CAAC,IAAI,MAAM;AACb,QAAI,OAAO,IAAI,CAAC,KAAK,EAAE,OAAO,oBAAoB,CAAC;AACnD;;GAGF,MAAM,OAAO,IAAI,KAAK;GACtB,MAAM,YAAY,IAAI,KAAK;AAE3B,OAAI,CAAC,MAAM;AACT,QAAI,OAAO,IAAI,CAAC,KAAK,EAAE,OAAO,oBAAoB,CAAC;AACnD;;GAGF,MAAM,KAAK,MAAM,YAAY;IAC3B;IACA,QAAQ,IAAI,KAAK;IACjB;IACA,kBAAkB,IAAI,KAAK;IAC3B;IACD,CAAC;AAEF,OAAI,OAAO,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC;WACrB,OAAO;AACd,WAAQ,MAAM,0BAA0B,MAAM;AAC9C,OAAI,OAAO,IAAI,CAAC,KAAK,EAAE,OAAO,0BAA0B,CAAC;;GAG9D;AAGD,QAAO,IAAI,GAAG,SAAS,OAAO,OAAO,KAAc,QAAkB;AACnE,MAAI;GACF,MAAM,EAAE,OAAO,IAAI;GAEnB,MAAM,QAAQ,MAAM,OAAO,QAAQ,WAAW;IAC5C,OAAO,EAAE,IAAI;IACb,QAAQ;KACN,SAAS;KACT,UAAU;KACV,MAAM;KACN,OAAO;KACP,QAAQ;KACT;IACF,CAAC;AAEF,OAAI,CAAC,OAAO;AACV,QAAI,OAAO,IAAI,CAAC,KAAK,EAAE,OAAO,mBAAmB,CAAC;AAClD;;GAGF,MAAM,gBACJ,OAAO,QAAQ,IAAI,4BAA4B,OAAO,KAAK;GAC7D,MAAM,SAAS,IAAI,MAAM;GACzB,MAAM,QAAQ,SAAS,OAAO,SAAS,QAAQ,GAAG,GAAG;GAErD,IAAI,YAAwB,MAAM;GAClC,IAAI,UAAU,MAAM;AASpB,OANE,iBACA,cAAc,MAAM,SAAS,IAC7B,CAAC,CAAC,SACF,OAAO,SAAS,MAAM,IACtB,QAAQ,GAEQ;IAChB,MAAM,MAAM,eAAe,MAAM,SAAS,IAAI;IAC9C,MAAM,MAAM,MAAM,YAChB,OAAO,KAAK,MAAM,QAAQ,EAC1B,OACA,QACA,IACD;AACD,gBAAY,IAAI,WAAW,IAAI;AAC/B,cAAU,SAAS;;AAIrB,OAAI,UAAU,gBAAgB,QAAQ;AACtC,OAAI,UAAU,uBAAuB,qBAAqB,MAAM,KAAK,GAAG;AACxE,OAAI,UAAU,iBAAiB,wBAAwB;AAGvD,OAAI,KAAK,UAAU;WACZ,OAAO;AACd,WAAQ,MAAM,yBAAyB,MAAM;AAC7C,OAAI,OAAO,IAAI,CAAC,KAAK,EAAE,OAAO,yBAAyB,CAAC;;GAE1D;AAGF,QAAO,IACL,GAAG,SAAS,gBACZ,OAAO,KAAc,QAAkB;AACrC,MAAI;GACF,MAAM,EAAE,OAAO,IAAI;GAEnB,MAAM,QAAQ,MAAM,OAAO,QAAQ,WAAW;IAC5C,OAAO,EAAE,IAAI;IACb,QAAQ;KACN,IAAI;KACJ,MAAM;KACN,WAAW;KACX,UAAU;KACV,UAAU;KACV,OAAO;KACP,QAAQ;KACR,WAAW;KACX,WAAW;KACZ;IACF,CAAC;AAEF,OAAI,CAAC,OAAO;AACV,QAAI,OAAO,IAAI,CAAC,KAAK,EAAE,OAAO,mBAAmB,CAAC;AAClD;;AAGF,OAAI,KAAK,MAAM;WACR,OAAO;AACd,WAAQ,MAAM,kCAAkC,MAAM;AACtD,OAAI,OAAO,IAAI,CAAC,KAAK,EAAE,OAAO,kCAAkC,CAAC;;GAGtE;AAGD,QAAO,IACL,GAAG,SAAS,iBACZ,OAAO,KAAc,QAAkB;AACrC,MAAI;GACF,MAAM,EAAE,SAAS,IAAI;GAErB,MAAM,QAAQ,MAAM,OAAO,QAAQ,WAAW;IAC5C,OAAO,EAAE,MAAM;IACf,QAAQ;KACN,SAAS;KACT,UAAU;KACV,MAAM;KACN,OAAO;KACP,QAAQ;KACT;IACF,CAAC;AAEF,OAAI,CAAC,OAAO;AACV,QAAI,OAAO,IAAI,CAAC,KAAK,EAAE,OAAO,mBAAmB,CAAC;AAClD;;GAGF,MAAM,gBACJ,OAAO,QAAQ,IAAI,4BAA4B,OAAO,KAAK;GAC7D,MAAM,SAAS,IAAI,MAAM;GACzB,MAAM,QAAQ,SAAS,OAAO,SAAS,QAAQ,GAAG,GAAG;GAErD,IAAI,YAAwB,MAAM;GAClC,IAAI,UAAU,MAAM;GAEpB,MAAM,WACJ,MAAM,SAAS,WAAW,SAAS,IAAI,CAAC,MAAM,SAAS,SAAS,MAAM;AAQxE,OANE,iBACA,YACA,CAAC,CAAC,SACF,OAAO,SAAS,MAAM,IACtB,QAAQ,GAEQ;IAChB,MAAM,MAAM,MAAM,SAAS,SAAS,MAAM,GACtC,QACA,MAAM,SAAS,SAAS,OAAO,GAC7B,SACA;IAEN,IAAI;IACJ,MAAM,WAAW,MAAM,OAAO,KAAK,MAAM,QAAQ,CAAC,CAAC,OAAO;KACxD;KACA,KAAK;KACL,oBAAoB;KACrB,CAAC;AACF,QAAI,QAAQ,OAAO;AACjB,WAAM,MAAM,SAAS,KAAK,CAAC,UAAU;AACrC,eAAU;eACD,QAAQ,QAAQ;AACzB,WAAM,MAAM,SAAS,MAAM,CAAC,UAAU;AACtC,eAAU;WACL;AACL,WAAM,MAAM,SAAS,MAAM,CAAC,UAAU;AACtC,eAAU;;AAEZ,gBAAY,IAAI,WAAW,IAAI;;AAIjC,OAAI,UAAU,gBAAgB,QAAQ;AACtC,OAAI,UAAU,uBAAuB,qBAAqB,MAAM,KAAK,GAAG;AACxE,OAAI,UAAU,iBAAiB,wBAAwB;AAGvD,OAAI,KAAK,UAAU;WACZ,OAAO;AACd,WAAQ,MAAM,iCAAiC,MAAM;AACrD,OAAI,OAAO,IAAI,CAAC,KAAK,EAAE,OAAO,yBAAyB,CAAC;;GAG7D"}
1
+ {"version":3,"file":"assetRestController.mjs","names":[],"sources":["../../../src/modules/assets/assetRestController.ts"],"sourcesContent":["import type { Request, Response, Router } from 'express'\nimport multer from 'multer'\nimport sharp from 'sharp'\nimport { getDbClient } from '../../db'\nimport { getImageFormat, isRasterImage, resizeImage } from './assetUtils'\nimport { upsertAsset } from './upsertAsset'\nimport { isNotModified, setRevalidatingCacheHeaders } from './assetCache'\n\n// Configure multer for memory storage\nconst upload = multer({\n storage: multer.memoryStorage(),\n limits: {\n fileSize: 10 * 1024 * 1024, // 10MB limit\n },\n fileFilter: (_req, file, cb) => {\n // Accept images only\n if (!file.mimetype.startsWith('image/')) {\n cb(new Error('Only image files are allowed'))\n return\n }\n cb(null, true)\n },\n})\n\nexport interface AssetRestControllerConfig {\n /**\n * Base path for asset endpoints (e.g., '/api/assets')\n */\n basePath: string\n}\n\nexport interface ParseAssetParams {\n buffer: Buffer\n originalFilename: string\n fileSize?: number\n}\n\nexport interface ParseAssetReturn {\n checksum: string\n fileSize: number\n mimeType: string\n width?: number\n height?: number\n}\n\n/**\n * Registers REST endpoints for universal asset upload and retrieval\n *\n * Endpoints:\n * - POST {basePath}/upload - Upload a new asset (multipart/form-data)\n * - GET {basePath}/:id - Get asset binary by ID\n * - GET {basePath}/:id/metadata - Get asset metadata only\n * - GET {basePath}/by-name/:name - Get asset binary by name\n */\nexport function registerAssetRestController(\n router: Router,\n config: AssetRestControllerConfig,\n): void {\n const { basePath } = config\n const prisma = getDbClient()\n\n // Upload endpoint - accepts multipart/form-data\n router.post(\n `${basePath}/upload`,\n upload.single('asset'),\n async (req: Request, res: Response) => {\n try {\n if (!req.file) {\n res.status(400).json({ error: 'No file uploaded' })\n return\n }\n\n const name = req.body['name'] as string\n const assetType = req.body['assetType']\n\n if (!name) {\n res.status(400).json({ error: 'Name is required' })\n return\n }\n\n const id = await upsertAsset({\n prisma,\n buffer: req.file.buffer,\n name,\n originalFilename: req.file.filename,\n assetType,\n })\n\n res.status(201).json({ id })\n } catch (error) {\n console.error('Error uploading asset:', error)\n res.status(500).json({ error: 'Failed to upload asset' })\n }\n },\n )\n\n // Get asset binary by ID\n router.get(`${basePath}/:id`, async (req: Request, res: Response) => {\n try {\n const { id } = req.params\n\n const asset = await prisma.dbAsset.findUnique({\n where: { id },\n select: {\n content: true,\n mimeType: true,\n name: true,\n width: true,\n height: true,\n checksum: true,\n },\n })\n\n if (!asset) {\n res.status(404).json({ error: 'Asset not found' })\n return\n }\n\n const resizeEnabled =\n String(process.env.EH_ASSETS_RESIZE_ENABLED || 'true') === 'true'\n const wParam = req.query['w'] as string | undefined\n const width = wParam ? Number.parseInt(wParam, 10) : undefined\n\n let outBuffer: Uint8Array = asset.content\n let outMime = asset.mimeType\n\n const shouldResize =\n resizeEnabled &&\n isRasterImage(asset.mimeType) &&\n !!width &&\n Number.isFinite(width) &&\n width > 0\n\n // Answered before resizing, so an unchanged asset costs no image work.\n const etag = setRevalidatingCacheHeaders(\n res,\n asset.checksum,\n shouldResize ? `w${width}` : undefined,\n )\n if (isNotModified(req, etag)) {\n res.status(304).end()\n return\n }\n\n if (shouldResize) {\n const fmt = getImageFormat(asset.mimeType) || 'jpeg'\n const buf = await resizeImage(\n Buffer.from(asset.content),\n width,\n undefined,\n fmt,\n )\n outBuffer = new Uint8Array(buf)\n outMime = `image/${fmt}`\n }\n\n // Set appropriate headers\n res.setHeader('Content-Type', outMime)\n res.setHeader('Content-Disposition', `inline; filename=\"${asset.name}\"`)\n\n // Send binary content (resized if requested)\n res.send(outBuffer)\n } catch (error) {\n console.error('Error fetching asset:', error)\n res.status(500).json({ error: 'Failed to fetch asset' })\n }\n })\n\n // Get asset metadata only (no binary content)\n router.get(\n `${basePath}/:id/metadata`,\n async (req: Request, res: Response) => {\n try {\n const { id } = req.params\n\n const asset = await prisma.dbAsset.findUnique({\n where: { id },\n select: {\n id: true,\n name: true,\n assetType: true,\n mimeType: true,\n fileSize: true,\n width: true,\n height: true,\n createdAt: true,\n updatedAt: true,\n },\n })\n\n if (!asset) {\n res.status(404).json({ error: 'Asset not found' })\n return\n }\n\n res.json(asset)\n } catch (error) {\n console.error('Error fetching asset metadata:', error)\n res.status(500).json({ error: 'Failed to fetch asset metadata' })\n }\n },\n )\n\n // Get asset binary by name\n router.get(\n `${basePath}/by-name/:name`,\n async (req: Request, res: Response) => {\n try {\n const { name } = req.params\n\n const asset = await prisma.dbAsset.findUnique({\n where: { name },\n select: {\n content: true,\n mimeType: true,\n name: true,\n width: true,\n height: true,\n checksum: true,\n },\n })\n\n if (!asset) {\n res.status(404).json({ error: 'Asset not found' })\n return\n }\n\n const resizeEnabled =\n String(process.env.EH_ASSETS_RESIZE_ENABLED || 'true') === 'true'\n const wParam = req.query['w'] as string | undefined\n const width = wParam ? Number.parseInt(wParam, 10) : undefined\n\n let outBuffer: Uint8Array = asset.content\n let outMime = asset.mimeType\n\n const isRaster =\n asset.mimeType.startsWith('image/') && !asset.mimeType.includes('svg')\n const shouldResize =\n resizeEnabled &&\n isRaster &&\n !!width &&\n Number.isFinite(width) &&\n width > 0\n\n // Answered before resizing, so an unchanged asset costs no image work.\n const etag = setRevalidatingCacheHeaders(\n res,\n asset.checksum,\n shouldResize ? `w${width}` : undefined,\n )\n if (isNotModified(req, etag)) {\n res.status(304).end()\n return\n }\n\n if (shouldResize) {\n const fmt = asset.mimeType.includes('png')\n ? 'png'\n : asset.mimeType.includes('webp')\n ? 'webp'\n : 'jpeg'\n\n let buf: Buffer\n const pipeline = sharp(Buffer.from(asset.content)).resize({\n width,\n fit: 'inside',\n withoutEnlargement: true,\n })\n if (fmt === 'png') {\n buf = await pipeline.png().toBuffer()\n outMime = 'image/png'\n } else if (fmt === 'webp') {\n buf = await pipeline.webp().toBuffer()\n outMime = 'image/webp'\n } else {\n buf = await pipeline.jpeg().toBuffer()\n outMime = 'image/jpeg'\n }\n outBuffer = new Uint8Array(buf)\n }\n\n // Set appropriate headers\n res.setHeader('Content-Type', outMime)\n res.setHeader('Content-Disposition', `inline; filename=\"${asset.name}\"`)\n\n // Send binary content (resized if requested)\n res.send(outBuffer)\n } catch (error) {\n console.error('Error fetching asset by name:', error)\n res.status(500).json({ error: 'Failed to fetch asset' })\n }\n },\n )\n}\n"],"mappings":"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"}
@@ -76,6 +76,7 @@ async function parseAssetMeta(p) {
76
76
  tiff: "image/tiff",
77
77
  gif: "image/gif",
78
78
  heif: "image/heif",
79
+ svg: "image/svg+xml",
79
80
  raw: "application/octet-stream"
80
81
  }[format] ?? `image/${format}` : "application/octet-stream",
81
82
  fileSize: size || 0
@@ -1 +1 @@
1
- {"version":3,"file":"assetUtils.mjs","names":[],"sources":["../../../src/modules/assets/assetUtils.ts"],"sourcesContent":["import { createHash } from 'node:crypto'\nimport type { FormatEnum } from 'sharp'\nimport sharp from 'sharp'\nimport type { ParseAssetParams, ParseAssetReturn } from './assetRestController'\n\n/**\n * Extract image dimensions from a buffer using sharp\n */\nexport async function getImageDimensions(\n buffer: Buffer,\n): Promise<{ width?: number; height?: number }> {\n try {\n const metadata = await sharp(buffer).metadata()\n return {\n width: metadata.width,\n height: metadata.height,\n }\n } catch (error) {\n console.error('Error extracting image dimensions:', error)\n return { width: undefined, height: undefined }\n }\n}\n\n/**\n * Resize an image buffer to the specified dimensions\n * @param buffer - The image buffer to resize\n * @param width - Target width (optional)\n * @param height - Target height (optional)\n * @param format - Output format ('png', 'jpeg', 'webp'), auto-detected if not provided\n */\nexport async function resizeImage(\n buffer: Buffer,\n width?: number,\n height?: number,\n format?: 'png' | 'jpeg' | 'webp',\n): Promise<Buffer> {\n let pipeline = sharp(buffer)\n\n // Apply resize if dimensions provided\n if (width || height) {\n pipeline = pipeline.resize({\n width,\n height,\n fit: 'inside',\n withoutEnlargement: true,\n })\n }\n\n // Apply format conversion if specified\n if (format === 'png') {\n pipeline = pipeline.png()\n } else if (format === 'webp') {\n pipeline = pipeline.webp()\n } else if (format === 'jpeg') {\n pipeline = pipeline.jpeg()\n }\n\n return pipeline.toBuffer()\n}\n\n/**\n * Generate SHA-256 checksum for a buffer\n */\nexport function generateChecksum(buffer: Buffer): string {\n return createHash('sha256').update(buffer).digest('hex')\n}\n\n/**\n * Detect image format from mime type\n */\nexport function getImageFormat(\n mimeType: string,\n): 'png' | 'webp' | 'jpeg' | null {\n if (mimeType.includes('png')) return 'png'\n if (mimeType.includes('webp')) return 'webp'\n if (mimeType.includes('jpeg') || mimeType.includes('jpg')) return 'jpeg'\n return null\n}\n\n/**\n * Check if a mime type represents a raster image (not SVG)\n */\nexport function isRasterImage(mimeType: string): boolean {\n return mimeType.startsWith('image/') && !mimeType.includes('svg')\n}\n\nexport async function parseAssetMeta(\n p: ParseAssetParams,\n): Promise<ParseAssetReturn> {\n // Get image dimensions using our utility\n const { width, height, format, size } = await sharp(p.buffer).metadata()\n\n const formatToMime: Partial<Record<keyof FormatEnum, string>> = {\n jpeg: 'image/jpeg',\n jpg: 'image/jpeg',\n png: 'image/png',\n webp: 'image/webp',\n avif: 'image/avif',\n tiff: 'image/tiff',\n gif: 'image/gif',\n heif: 'image/heif',\n raw: 'application/octet-stream',\n }\n\n return {\n checksum: generateChecksum(p.buffer),\n width,\n height,\n mimeType: format\n ? (formatToMime[format] ?? `image/${format}`)\n : 'application/octet-stream',\n fileSize: size || 0,\n }\n}\n"],"mappings":";;;;;;;AAQA,eAAsB,mBACpB,QAC8C;AAC9C,KAAI;EACF,MAAM,WAAW,MAAM,MAAM,OAAO,CAAC,UAAU;AAC/C,SAAO;GACL,OAAO,SAAS;GAChB,QAAQ,SAAS;GAClB;UACM,OAAO;AACd,UAAQ,MAAM,sCAAsC,MAAM;AAC1D,SAAO;GAAE,OAAO;GAAW,QAAQ;GAAW;;;;;;;;;;AAWlD,eAAsB,YACpB,QACA,OACA,QACA,QACiB;CACjB,IAAI,WAAW,MAAM,OAAO;AAG5B,KAAI,SAAS,OACX,YAAW,SAAS,OAAO;EACzB;EACA;EACA,KAAK;EACL,oBAAoB;EACrB,CAAC;AAIJ,KAAI,WAAW,MACb,YAAW,SAAS,KAAK;UAChB,WAAW,OACpB,YAAW,SAAS,MAAM;UACjB,WAAW,OACpB,YAAW,SAAS,MAAM;AAG5B,QAAO,SAAS,UAAU;;;;;AAM5B,SAAgB,iBAAiB,QAAwB;AACvD,QAAO,WAAW,SAAS,CAAC,OAAO,OAAO,CAAC,OAAO,MAAM;;;;;AAM1D,SAAgB,eACd,UACgC;AAChC,KAAI,SAAS,SAAS,MAAM,CAAE,QAAO;AACrC,KAAI,SAAS,SAAS,OAAO,CAAE,QAAO;AACtC,KAAI,SAAS,SAAS,OAAO,IAAI,SAAS,SAAS,MAAM,CAAE,QAAO;AAClE,QAAO;;;;;AAMT,SAAgB,cAAc,UAA2B;AACvD,QAAO,SAAS,WAAW,SAAS,IAAI,CAAC,SAAS,SAAS,MAAM;;AAGnE,eAAsB,eACpB,GAC2B;CAE3B,MAAM,EAAE,OAAO,QAAQ,QAAQ,SAAS,MAAM,MAAM,EAAE,OAAO,CAAC,UAAU;AAcxE,QAAO;EACL,UAAU,iBAAiB,EAAE,OAAO;EACpC;EACA;EACA,UAAU,SAhBoD;GAC9D,MAAM;GACN,KAAK;GACL,KAAK;GACL,MAAM;GACN,MAAM;GACN,MAAM;GACN,KAAK;GACL,MAAM;GACN,KAAK;GACN,CAOmB,WAAW,SAAS,WAClC;EACJ,UAAU,QAAQ;EACnB"}
1
+ {"version":3,"file":"assetUtils.mjs","names":[],"sources":["../../../src/modules/assets/assetUtils.ts"],"sourcesContent":["import { createHash } from 'node:crypto'\nimport type { FormatEnum } from 'sharp'\nimport sharp from 'sharp'\nimport type { ParseAssetParams, ParseAssetReturn } from './assetRestController'\n\n/**\n * Extract image dimensions from a buffer using sharp\n */\nexport async function getImageDimensions(\n buffer: Buffer,\n): Promise<{ width?: number; height?: number }> {\n try {\n const metadata = await sharp(buffer).metadata()\n return {\n width: metadata.width,\n height: metadata.height,\n }\n } catch (error) {\n console.error('Error extracting image dimensions:', error)\n return { width: undefined, height: undefined }\n }\n}\n\n/**\n * Resize an image buffer to the specified dimensions\n * @param buffer - The image buffer to resize\n * @param width - Target width (optional)\n * @param height - Target height (optional)\n * @param format - Output format ('png', 'jpeg', 'webp'), auto-detected if not provided\n */\nexport async function resizeImage(\n buffer: Buffer,\n width?: number,\n height?: number,\n format?: 'png' | 'jpeg' | 'webp',\n): Promise<Buffer> {\n let pipeline = sharp(buffer)\n\n // Apply resize if dimensions provided\n if (width || height) {\n pipeline = pipeline.resize({\n width,\n height,\n fit: 'inside',\n withoutEnlargement: true,\n })\n }\n\n // Apply format conversion if specified\n if (format === 'png') {\n pipeline = pipeline.png()\n } else if (format === 'webp') {\n pipeline = pipeline.webp()\n } else if (format === 'jpeg') {\n pipeline = pipeline.jpeg()\n }\n\n return pipeline.toBuffer()\n}\n\n/**\n * Generate SHA-256 checksum for a buffer\n */\nexport function generateChecksum(buffer: Buffer): string {\n return createHash('sha256').update(buffer).digest('hex')\n}\n\n/**\n * Detect image format from mime type\n */\nexport function getImageFormat(\n mimeType: string,\n): 'png' | 'webp' | 'jpeg' | null {\n if (mimeType.includes('png')) return 'png'\n if (mimeType.includes('webp')) return 'webp'\n if (mimeType.includes('jpeg') || mimeType.includes('jpg')) return 'jpeg'\n return null\n}\n\n/**\n * Check if a mime type represents a raster image (not SVG)\n */\nexport function isRasterImage(mimeType: string): boolean {\n return mimeType.startsWith('image/') && !mimeType.includes('svg')\n}\n\nexport async function parseAssetMeta(\n p: ParseAssetParams,\n): Promise<ParseAssetReturn> {\n // Get image dimensions using our utility\n const { width, height, format, size } = await sharp(p.buffer).metadata()\n\n const formatToMime: Partial<Record<keyof FormatEnum, string>> = {\n jpeg: 'image/jpeg',\n jpg: 'image/jpeg',\n png: 'image/png',\n webp: 'image/webp',\n avif: 'image/avif',\n tiff: 'image/tiff',\n gif: 'image/gif',\n heif: 'image/heif',\n svg: 'image/svg+xml',\n raw: 'application/octet-stream',\n }\n\n return {\n checksum: generateChecksum(p.buffer),\n width,\n height,\n mimeType: format\n ? (formatToMime[format] ?? `image/${format}`)\n : 'application/octet-stream',\n fileSize: size || 0,\n }\n}\n"],"mappings":";;;;;;;AAQA,eAAsB,mBACpB,QAC8C;AAC9C,KAAI;EACF,MAAM,WAAW,MAAM,MAAM,OAAO,CAAC,UAAU;AAC/C,SAAO;GACL,OAAO,SAAS;GAChB,QAAQ,SAAS;GAClB;UACM,OAAO;AACd,UAAQ,MAAM,sCAAsC,MAAM;AAC1D,SAAO;GAAE,OAAO;GAAW,QAAQ;GAAW;;;;;;;;;;AAWlD,eAAsB,YACpB,QACA,OACA,QACA,QACiB;CACjB,IAAI,WAAW,MAAM,OAAO;AAG5B,KAAI,SAAS,OACX,YAAW,SAAS,OAAO;EACzB;EACA;EACA,KAAK;EACL,oBAAoB;EACrB,CAAC;AAIJ,KAAI,WAAW,MACb,YAAW,SAAS,KAAK;UAChB,WAAW,OACpB,YAAW,SAAS,MAAM;UACjB,WAAW,OACpB,YAAW,SAAS,MAAM;AAG5B,QAAO,SAAS,UAAU;;;;;AAM5B,SAAgB,iBAAiB,QAAwB;AACvD,QAAO,WAAW,SAAS,CAAC,OAAO,OAAO,CAAC,OAAO,MAAM;;;;;AAM1D,SAAgB,eACd,UACgC;AAChC,KAAI,SAAS,SAAS,MAAM,CAAE,QAAO;AACrC,KAAI,SAAS,SAAS,OAAO,CAAE,QAAO;AACtC,KAAI,SAAS,SAAS,OAAO,IAAI,SAAS,SAAS,MAAM,CAAE,QAAO;AAClE,QAAO;;;;;AAMT,SAAgB,cAAc,UAA2B;AACvD,QAAO,SAAS,WAAW,SAAS,IAAI,CAAC,SAAS,SAAS,MAAM;;AAGnE,eAAsB,eACpB,GAC2B;CAE3B,MAAM,EAAE,OAAO,QAAQ,QAAQ,SAAS,MAAM,MAAM,EAAE,OAAO,CAAC,UAAU;AAexE,QAAO;EACL,UAAU,iBAAiB,EAAE,OAAO;EACpC;EACA;EACA,UAAU,SAjBoD;GAC9D,MAAM;GACN,KAAK;GACL,KAAK;GACL,MAAM;GACN,MAAM;GACN,MAAM;GACN,KAAK;GACL,MAAM;GACN,KAAK;GACL,KAAK;GACN,CAOmB,WAAW,SAAS,WAClC;EACJ,UAAU,QAAQ;EACnB"}
@@ -1 +1 @@
1
- {"version":3,"file":"screenshotRestController.d.mts","names":[],"sources":["../../../src/modules/assets/screenshotRestController.ts"],"mappings":";;;UAIiB,8BAAA;;AAAjB;;EAIE,QAAA;AAAA;;AAWF;;;;;;;iBAAgB,gCAAA,CACd,MAAA,EAAQ,MAAA,EACR,MAAA,EAAQ,8BAAA"}
1
+ {"version":3,"file":"screenshotRestController.d.mts","names":[],"sources":["../../../src/modules/assets/screenshotRestController.ts"],"mappings":";;;UAKiB,8BAAA;;AAAjB;;EAIE,QAAA;AAAA;;AAWF;;;;;;;iBAAgB,gCAAA,CACd,MAAA,EAAQ,MAAA,EACR,MAAA,EAAQ,8BAAA"}
@@ -1,4 +1,5 @@
1
1
  import { getDbClient } from "../../db/client.mjs";
2
+ import { isNotModified, setRevalidatingCacheHeaders } from "./assetCache.mjs";
2
3
  import sharp from "sharp";
3
4
 
4
5
  //#region src/modules/assets/screenshotRestController.ts
@@ -90,13 +91,18 @@ function registerScreenshotRestController(router, config) {
90
91
  select: {
91
92
  content: true,
92
93
  mimeType: true,
93
- name: true
94
+ name: true,
95
+ checksum: true
94
96
  }
95
97
  });
96
98
  if (!screenshot) {
97
99
  res.status(404).json({ error: "Screenshot not found" });
98
100
  return;
99
101
  }
102
+ if (isNotModified(req, setRevalidatingCacheHeaders(res, screenshot.checksum, targetSize && targetSize > 0 ? `s${targetSize}` : void 0))) {
103
+ res.status(304).end();
104
+ return;
105
+ }
100
106
  let content = screenshot.content;
101
107
  if (targetSize && targetSize > 0) try {
102
108
  content = await sharp(screenshot.content).resize(targetSize, targetSize, {
@@ -108,7 +114,6 @@ function registerScreenshotRestController(router, config) {
108
114
  }
109
115
  res.setHeader("Content-Type", screenshot.mimeType);
110
116
  res.setHeader("Content-Disposition", `inline; filename="${screenshot.name}"`);
111
- res.setHeader("Cache-Control", "public, max-age=86400");
112
117
  res.send(content);
113
118
  } catch (error) {
114
119
  console.error("Error fetching screenshot:", error);