{"id":"6a3a8e02d29e4d59da1d63c5","title":"2026 06 23 Build 718","path":"how-to/committee/release-notes/2026-06-23-build-718","contentMarkdown":"# 23-Jun-2026 — run resize on the integration worker, defer drop-time resize until after cropping, and... [#257](https://github.com/nbarrett/ngx-ramblers/issues/257)\n\n## [build 718](https://github.com/nbarrett/ngx-ramblers/actions/runs/28029913911) — [commit 58114fc](https://github.com/nbarrett/ngx-ramblers/commit/58114fc62bb330ea26d0135f00bad0108d0c82db)\n\n_____\n\nWorker move (the original #257 scope):\nPer-site 512MB Fly VMs were being OOM-killed by sharp during large album\nresizes, taking the whole site process down. The sharp/S3 work now runs on\nthe 1024MB shared integration worker, mirroring the existing migration job\npattern: the site dispatches a job, the worker runs it and posts\nprogress/result callbacks, and the site relays those onto the existing\nWebSocket so the editor UI is unchanged.\n\n- Extract the sharp/S3 core into image-resize-engine.ts, taking an injected\nS3 client and a progress reporter (no ws, no Mongo).\n- bulk-image-resizer.ts becomes a thin local-path wrapper over the engine,\npreserving the local-dev fallback when no worker is configured.\n- Worker intake at /api/integration-worker/resize/jobs builds its S3 client\nfrom per-site AWS credentials passed encrypted in the job, so the shared\nworker stays multi-tenant and reads/writes the correct site bucket.\n- Content-metadata Mongo writes stay on the site: the worker returns the\nupdated metadata and the site result callback commits it.\n- WebSocket message contract preserved (PROGRESS/COMPLETE/ERROR), so the\nalbum editor needs no changes.\n- The worker logs every resize operation to its own debug log: job start\n(mode, image count, bucket, target size), each per-image step with\nsequential op number, percent and elapsed ms, and job finish/failure with\ntotals and elapsed time.\n\nDrop-then-crop resize UX:\nDropping images into the album editor immediately compressed each whole\nimage to the maximum size, so cropping then kept a region of an\nalready-degraded image and the byte budget was spent on the whole frame\nrather than the part kept.\n\n- On drop, images are only downscaled by dimension (max 2400px wide, high\nquality) to keep the cropper responsive and bound memory on bulk drops -\nno byte-target compression.\n- The byte-target compression is deferred to save and applied to each\nimage's current base64 (the cropped result if it was cropped), so the\nkept crop gets the full size budget.\n- New file-utils downscaleBase64Image helper does the dimension-only scaling.\n\nResize byte-budget fix:\nresizeBase64Image compared the base64 string length against a budget derived\nstraight from the byte target. Base64 is ~4/3 larger than the bytes it\nencodes, so a 250kb cap produced ~169kb images (roughly target x 0.74). The\nbudget now converts bytes to base64 chars (x 4/3, with a 2% safety margin),\nso output lands just under the cap instead of well below it - and the\ndimension grow-step can use the full budget for sharper images.\n\nBulk-drop read resilience:\nDropping many files at once read them all concurrently via Promise.all; a\nsingle FileReader NotFoundError (common when reading 50+ dropped files at\nonce) rejected the whole batch as an unhandled error, so nothing was\ninserted. Reads now run in bounded-concurrency batches (6 at a time) with a\nsingle retry per file; unreadable files are skipped with a warning showing\nhow many, instead of failing the entire drop. onFileSelectOrDropped is\nwrapped so it can never surface an unhandled rejection.\n\nEditor display fixes:\n- \"Cropped Size NaN undefinedb\" appeared for freshly dropped (uncropped)\nimages because croppedSize() read only the cropped file size. It now falls\nback to the dropped image's actual size from base64, and the row is hidden\nwhen there is no size.\n- humanFileSize returns \"\" for non-finite input instead of \"NaN undefinedb\",\nso this class of display bug cannot recur anywhere it is used; the function\nwas also restructured to if/else-if/else (no scattered early returns) and\noff let.\n\nLint hardening (prevent recurrence):\nBoth Date.now() and Object.entries() were documented as forbidden in\nAGENTS.md but had no AST selector, so they passed lint and were only caught\nat review. Audited the whole AGENTS.md forbidden table; these two were the\nonly documented-but-unenforced rules (new Date, DateTime.now, typeof checks,\nArray.isArray, the loop rules and inline comments were already enforced).\n\n- Added no-restricted-syntax selectors for Date.now() and Object.entries()\nto the server and frontend blocks.\n- Migrated existing Date.now() usages to dateTimeNowAsValue() (backend) /\nthis.dateUtils.nowAsValue() (frontend) across integration-worker\nserver/runner/client/routes, fly-commands, email-composition,\nbrevo-events-webhook, site-migration-ws-handler, the two deploy scripts,\nthe s3-utils spec and banner.component.ts.\n- Migrated existing Object.entries() usages to toPairs() from\nes-toolkit/compat across 28 files (geojson, brevo, migrations, salesforce,\nvenue, map-routes, walks, area-map, mail, content admin, etc.).\n- Corrected AGENTS.md: the es-toolkit/compat equivalent of Object.entries is\ntoPairs(), not entries() (which it does not export).","contentHtml":"<h1>23-Jun-2026 — run resize on the integration worker, defer drop-time resize until after cropping, and... <a href=\"https://github.com/nbarrett/ngx-ramblers/issues/257\">#257</a></h1>\n<h2><a href=\"https://github.com/nbarrett/ngx-ramblers/actions/runs/28029913911\">build 718</a> — <a href=\"https://github.com/nbarrett/ngx-ramblers/commit/58114fc62bb330ea26d0135f00bad0108d0c82db\">commit 58114fc</a></h2>\n<hr>\n<p>Worker move (the original #257 scope):\nPer-site 512MB Fly VMs were being OOM-killed by sharp during large album\nresizes, taking the whole site process down. The sharp/S3 work now runs on\nthe 1024MB shared integration worker, mirroring the existing migration job\npattern: the site dispatches a job, the worker runs it and posts\nprogress/result callbacks, and the site relays those onto the existing\nWebSocket so the editor UI is unchanged.</p>\n<ul>\n<li>Extract the sharp/S3 core into image-resize-engine.ts, taking an injected\nS3 client and a progress reporter (no ws, no Mongo).</li>\n<li>bulk-image-resizer.ts becomes a thin local-path wrapper over the engine,\npreserving the local-dev fallback when no worker is configured.</li>\n<li>Worker intake at /api/integration-worker/resize/jobs builds its S3 client\nfrom per-site AWS credentials passed encrypted in the job, so the shared\nworker stays multi-tenant and reads/writes the correct site bucket.</li>\n<li>Content-metadata Mongo writes stay on the site: the worker returns the\nupdated metadata and the site result callback commits it.</li>\n<li>WebSocket message contract preserved (PROGRESS/COMPLETE/ERROR), so the\nalbum editor needs no changes.</li>\n<li>The worker logs every resize operation to its own debug log: job start\n(mode, image count, bucket, target size), each per-image step with\nsequential op number, percent and elapsed ms, and job finish/failure with\ntotals and elapsed time.</li>\n</ul>\n<p>Drop-then-crop resize UX:\nDropping images into the album editor immediately compressed each whole\nimage to the maximum size, so cropping then kept a region of an\nalready-degraded image and the byte budget was spent on the whole frame\nrather than the part kept.</p>\n<ul>\n<li>On drop, images are only downscaled by dimension (max 2400px wide, high\nquality) to keep the cropper responsive and bound memory on bulk drops -\nno byte-target compression.</li>\n<li>The byte-target compression is deferred to save and applied to each\nimage&#39;s current base64 (the cropped result if it was cropped), so the\nkept crop gets the full size budget.</li>\n<li>New file-utils downscaleBase64Image helper does the dimension-only scaling.</li>\n</ul>\n<p>Resize byte-budget fix:\nresizeBase64Image compared the base64 string length against a budget derived\nstraight from the byte target. Base64 is ~4/3 larger than the bytes it\nencodes, so a 250kb cap produced ~169kb images (roughly target x 0.74). The\nbudget now converts bytes to base64 chars (x 4/3, with a 2% safety margin),\nso output lands just under the cap instead of well below it - and the\ndimension grow-step can use the full budget for sharper images.</p>\n<p>Bulk-drop read resilience:\nDropping many files at once read them all concurrently via Promise.all; a\nsingle FileReader NotFoundError (common when reading 50+ dropped files at\nonce) rejected the whole batch as an unhandled error, so nothing was\ninserted. Reads now run in bounded-concurrency batches (6 at a time) with a\nsingle retry per file; unreadable files are skipped with a warning showing\nhow many, instead of failing the entire drop. onFileSelectOrDropped is\nwrapped so it can never surface an unhandled rejection.</p>\n<p>Editor display fixes:</p>\n<ul>\n<li>&quot;Cropped Size NaN undefinedb&quot; appeared for freshly dropped (uncropped)\nimages because croppedSize() read only the cropped file size. It now falls\nback to the dropped image&#39;s actual size from base64, and the row is hidden\nwhen there is no size.</li>\n<li>humanFileSize returns &quot;&quot; for non-finite input instead of &quot;NaN undefinedb&quot;,\nso this class of display bug cannot recur anywhere it is used; the function\nwas also restructured to if/else-if/else (no scattered early returns) and\noff let.</li>\n</ul>\n<p>Lint hardening (prevent recurrence):\nBoth Date.now() and Object.entries() were documented as forbidden in\nAGENTS.md but had no AST selector, so they passed lint and were only caught\nat review. Audited the whole AGENTS.md forbidden table; these two were the\nonly documented-but-unenforced rules (new Date, DateTime.now, typeof checks,\nArray.isArray, the loop rules and inline comments were already enforced).</p>\n<ul>\n<li>Added no-restricted-syntax selectors for Date.now() and Object.entries()\nto the server and frontend blocks.</li>\n<li>Migrated existing Date.now() usages to dateTimeNowAsValue() (backend) /\nthis.dateUtils.nowAsValue() (frontend) across integration-worker\nserver/runner/client/routes, fly-commands, email-composition,\nbrevo-events-webhook, site-migration-ws-handler, the two deploy scripts,\nthe s3-utils spec and banner.component.ts.</li>\n<li>Migrated existing Object.entries() usages to toPairs() from\nes-toolkit/compat across 28 files (geojson, brevo, migrations, salesforce,\nvenue, map-routes, walks, area-map, mail, content admin, etc.).</li>\n<li>Corrected AGENTS.md: the es-toolkit/compat equivalent of Object.entries is\ntoPairs(), not entries() (which it does not export).</li>\n</ul>\n"}