Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
237 changes: 237 additions & 0 deletions ADOPTION-PLAN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
# Adoption plan — any framework host

`@adaptivestone/framework-module-resize@0.1.0` is a **shared** package. It must stay
comfortable in a greenfield app, a second product, and Insailing — not a private
adapter for one backend.

Insailing was only the first production probe. Lessons below are generalized.
Insailing-specific rollout lives in that app’s `docs/resize-module-host-plan.md`,
not in this module’s API.

---

## Product rule

The module owns: size identity, sharp, persist of `previews[]`, ready/missing.

The **host** owns: which model is media, which size catalogs exist, the public
JSON, when to fallback, delete, SVG sanitization, domain steps (blur, watermark).

If a feature only makes sense for one app’s field names or DTO, it does **not**
belong here.

---

## What every host hit (or will)

These are product bugs, not “Insailing quirks”:

| Problem | Why any host cares |
|---|---|
| `generate()` can return `[]` for success, missing original, SVG, or total failure | Every write path has to guess |
| Returned `previews` are **new** rows only; a second call looks like a failed generate | Every host that checks `.length` |
| Failed `formatPublicUrls` leaks `{ ready, missing }` as `output` | Easy to send garbage JSON to any frontend |
| No filesystem driver | Tests and local docker always reinvent 40 lines |
| S3 option named `publicUrl` shadows `publicUrl(ref)` | Anyone who copies the README into a class |
| `enqueueMissing` defaults **true** | Eager-only apps enqueue-or-log on every read |
| README / scaffold lead with Mongo queue + worker | First install looks harder than it is |
| Main entry loads `ResizeTask` | Fine on framework 5.3; noisy for eager-only / older peers |

Not the module’s job (stay on the host):

- Mapping `originalMetadata` → `original` (legacy schema).
- A named `generateAvatarPreviews` / Event helper.
- `resolveMany` (listings: `find().select(…).lean()` + a loop).
- Per-entity size lists (avatar vs boat vs “product card”).

---

## Keep

- One `Resizer` per process.
- `generate` / `resolve` / `prewarm`.
- Size keys (`720x720`, `620w`, `400h`, `fit`) and `previews[]`.
- Swappable storage / transport / mediaStore.
- Pipelines + hooks.
- Worker **optional**. Eager is a first-class, complete mode.

---

## Change (small, host-agnostic)

### 1. Honest `generate`

```ts
const { created, failed } = await resizer.generate({
media,
sizes, // host allowlist — never raw client width/height
});
```

| Case | Behavior |
|---|---|
| no `media.original` | throw `ResizeNoOriginalError` |
| SVG original | `{ created: [], failed: 0 }` — success |
| every requested variant fails | throw `ResizeGenerateError` |
| some fail | no throw, `failed > 0` |
| all identities already stored | `{ created: [], failed: 0 }` |

Name the array **`created`** (only this call). After persist, also append onto `media.previews`
so a same-request `resolve` sees the new rows.

Export the error classes. Any host can `instanceof` without string-matching logs.

### 2. Honest `resolve`

- No hook / hook throws → `output === undefined`. Never pass the raw decision off as a DTO.
- No `transport` → `enqueueMissing` defaults to **`false`**.
- Optional helper `formatPictureUrls(decision, { id, mediaType })` builds a **generic**
`<picture>` map:

```ts
{
mediaType?: string,
id?: string,
sizes: {
[sizeKey: string]: {
[format: string]: { url: string, contentType: string }
}
}
}
```

`sizeKey` is whatever identity already is (`720x720`, `620w`, `fit`).
This is a convenience, **not** “the Insailing contract”. Another app uses its own hook.

### 3. `LocalFsStorage`

```ts
import { LocalFsStorage } from '@adaptivestone/framework-module-resize/storage/fs.js';

new LocalFsStorage({
rootDir: './var/media',
publicBaseUrl: '/media',
});
```

Same `download` / `upload` / `publicUrl`. Option name `publicBaseUrl` (never `publicUrl`).

Default story for tests and first-week local: FS. S3 when the host has buckets.

### 4. S3 — document, do not fork

Already accepts `client`. Show that first in README:

```ts
new S3Storage({
bucketPublic,
bucketPrivate,
publicBaseUrl, // alias old `publicUrl` for one minor
client, // existing S3Client — env/keys stay in the host
});
```

No second S3 wrapper in the package.

### 5. Tiny shared helpers

```ts
isCatalogCovered(media, sizes, formats): boolean
resizeMediaSelect = 'original previews' // plus host fields they already need
```

`resizeMediaSelect` is the **module** fields `resolve`/`generate` read. Hosts append
`mediaType`, `name`, … themselves. Do not bake Insailing’s `mediaType` into the
constant as if every app has it — document:

```ts
.select(`${resizeMediaSelect} mediaType`)
```

or export only `['original', 'previews']` and let the host join.

Prefer:

```ts
export const resizeMediaPaths = ['original', 'previews'] as const;
```

Hosts: `.select(['mediaType', ...resizeMediaPaths])`.

### 6. Docs and scaffold: eager first

`resize-scaffold --eager` is already there. Make the README **open** with:

```ts
import { Resizer } from '@adaptivestone/framework-module-resize';
import { LocalFsStorage } from '@adaptivestone/framework-module-resize/storage/fs.js';

export const resizer = new Resizer({
storage: new LocalFsStorage({ rootDir: './var/media', publicBaseUrl: '/media' }),
});
// after Server.init()
await resizer.generate({ media, sizes: [{ width: 320, height: 320 }] });
const { decision } = await resizer.resolve({ media, sizes: [{ width: 320, height: 320 }] });
```

Queue, `ResizeTask`, `ResizeWorker` = a later section (“when listings are huge”).

Construct **after** `Server.init()` (or lazily on first request). Do not construct in
`server.ts` before `startServer()`.

### 7. Schema

Module schema stays `original` + `previews` (fragment).
New apps spread the fragment from day one — no `toMediaLike`.
Old apps dual-write. No aliases in `FrameworkMediaStore`.

### 8. `./eager` subpath — 0.3, not 0.2

Nice when a host must not load `ResizeTask`. Not required if peer is framework ≥ 5.2.

---

## Do not do

- Insailing field names, catalogs, or `getPublicUrls` shape as the only DTO.
- `app` on every method.
- Storage delete / CDN purge.
- Watermark, NSFW, plate blur in core (host `pipeline` steps).
- Change identity keys or default encode table.
- Require a worker.
- `resolveMany`.
- Map legacy `originalMetadata` / `resizedMetadata`.

---

## Ship

One PR on this branch — not five, no extra GitHub workflow. `npm test` + `types:check`, then publish **0.2.0** the usual manual way (`RELEASE.md`).

In that PR: honest `generate` / `resolve`, `LocalFsStorage`, S3 `publicBaseUrl`, `isCatalogCovered` + `resizeMediaPaths`, README eager+FS first, scaffold `--eager` uses FS (not a Mongo transport TODO).

---

## Done when (any new app)

A greenfield host, after scaffold `--eager` and one size list of their own:

- uploads an original onto `media.original`;
- calls `generate` and can tell success from failure without a wrapper;
- calls `resolve` and either maps `decision` or uses `formatPictureUrls`;
- runs locally and in CI with `LocalFsStorage` and no AWS;
- can add a second entity by passing another `sizes` array only.

If the next product still needs a 150-line wrapper, 0.2 failed.

---

## Appendix — Insailing (evidence only)

First probe: profile avatar, eager, dual schema, custom FS + S3 wrapper.

Next **in that app** (not module work): Auth → `resizeImageByHandle` (four catalogs,
including `620w`) → listing `.select` must include `original`/`previews`.
Sitemap `uploadRaw` and video are out of scope.

Do not let those catalogs or `.select('resizedMetadata')` leak into the package API.
73 changes: 48 additions & 25 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,11 @@ version-matched to the installed package — prefer it over training-data memory
Humans: see `README.md` (same folder). Full docs: https://framework.adaptivestone.com/docs/resize
API ground truth: the installed `dist/index.d.ts` (main entry) and `dist/types.d.ts`.

**What it is:** lazy image resizing for `@adaptivestone/framework`. Uploads store only the
original. The read path decides per size + format + filters whether a preview is ready or
missing: ready → URL now; missing → enqueued and generated by a separate sharp worker, then
appended to the host media doc's `previews[]`. Three modes share one core and one stored shape:
lazy (default, on read), pre-warm (`prewarm()` queues at upload), eager (`generate()` inline,
no queue/worker needed).
**What it is:** image resizing for `@adaptivestone/framework`. Uploads store only the
original. Three modes share one core and one stored shape: eager (`generate()` inline,
no queue/worker — start here), lazy (on read, worker fills `previews[]`), pre-warm
(`prewarm()` queues the catalog at upload). The read path decides per size + format +
filters whether a preview is ready or missing.

## Integrate (in order)

Expand All @@ -32,12 +31,14 @@ no queue/worker needed).
2. Scaffold the integration files (never overwrites existing files; `--force` to regenerate):

```bash
npx resize-scaffold # or: --eager (no queue/worker), --eject (full editable model)
npx resize-scaffold --eager # start here (no queue/worker)
# npx resize-scaffold # lazy: also emits ResizeTask + ResizeWorker
# npx resize-scaffold --eject # full editable ResizeTask model
```

Emits `src/resizer.ts` (construction site), `src/models/ResizeTask.ts` (thin shim),
`src/commands/ResizeWorker.ts` (re-export), `src/config/resize.ts` (editable config), and
appends a pointer to this guide into the host's `AGENTS.md`
`--eager` emits `src/resizer.ts` (LocalFsStorage already wired) + `src/config/resize.ts`.
Default (lazy) also emits `src/models/ResizeTask.ts` and `src/commands/ResizeWorker.ts`.
Appends a pointer to this guide into the host's `AGENTS.md`
(`--agents claude|print|skip` to redirect or suppress it).

3. Wire the drivers in `src/resizer.ts` — ONE constructor literal. `storage` is REQUIRED;
Expand All @@ -46,20 +47,24 @@ no queue/worker needed).

```ts
import { Resizer } from '@adaptivestone/framework-module-resize';
import { MongoTransport } from '@adaptivestone/framework-module-resize/transports/mongo.js';
import { S3Storage } from '@adaptivestone/framework-module-resize/storage/s3.js';
import { LocalFsStorage } from '@adaptivestone/framework-module-resize/storage/fs.js';

export const resizer = new Resizer({
transport: new MongoTransport(),
storage: new S3Storage({
bucketPublic: 'my-cdn',
bucketPrivate: 'my-originals',
publicUrl: 'https://cdn.example.com',
storage: new LocalFsStorage({
rootDir: './var/media',
publicBaseUrl: '/media',
}),
});
```

Other shipped drivers: `SqsTransport` from
Construct **after** `Server.init()` (or lazily on first request). Do not construct in
`server.ts` before `startServer()`.

Other shipped drivers: `S3Storage` from
`@adaptivestone/framework-module-resize/storage/s3.js` (options: `bucketPublic` required;
`publicBaseUrl` — alias of the old `publicUrl` for one minor; `client` first when the host
already has an `S3Client`), `MongoTransport` from
`@adaptivestone/framework-module-resize/transports/mongo.js`, `SqsTransport` from
`@adaptivestone/framework-module-resize/transports/sqs.js` (options: `queueUrl` required;
`region`, `endpoint`, `visibilityTimeout`, `heartbeatInterval`, `client`),
`FrameworkMediaStore` from `@adaptivestone/framework-module-resize/mediaStore/framework.js`,
Expand All @@ -68,8 +73,8 @@ no queue/worker needed).
(`QueueTransport`, `ResizeStorage`, `MediaStore`, `LockProvider`) — no `app` parameter;
a driver closes over its own client.

4. Add `import './resizer.ts';` once in `src/server.ts` — it must run in EVERY process
(API and worker).
4. Import `./resizer.ts` from the process that needs it (API; and the worker, if any)
**after** `Server.init()`.

5. Set the one required config field in the scaffolded `src/config/resize.ts`:
`mediaModelName: 'File'` (your host media model's name).
Expand All @@ -93,15 +98,17 @@ Read path (DTO builders / controllers). `resolve` NEVER throws and never runs sh
variants are enqueued and the decision is returned immediately:

```ts
import { getResizer } from '@adaptivestone/framework-module-resize';
import { formatPictureUrls, getResizer } from '@adaptivestone/framework-module-resize';

const { output } = await getResizer().resolve({
const { decision, output } = await getResizer().resolve({
media: fileDoc,
pipeline: 'default',
sizes: [{ width: 620 }, { fit: true }, { width: 300, height: 300, filters: { blur: 40 } }],
sizes: [{ width: 620 }, { fit: true }, { width: 300, height: 300 }],
ctx: { isOwner },
});
// `output` is whatever your formatPublicUrls hook returns; the raw decision is also available.
// `output` is your formatPublicUrls hook (undefined if no hook / hook throws).
// formatPictureUrls skips filtered variants — map `decision` for those.
const picture = output ?? formatPictureUrls(decision, { id: String(fileDoc.id) });
```

Upload handler, pre-warm mode (non-blocking; the worker fills the cache before the first read):
Expand All @@ -113,7 +120,22 @@ const { enqueued } = await getResizer().prewarm({ media: fileDoc, sizes: catalog
Upload handler, eager mode (blocking; requires a Resizer constructed WITHOUT `transport`):

```ts
const { previews } = await getResizer().generate({ media: fileDoc, sizes: catalog });
const { created, failed } = await getResizer().generate({
media: fileDoc,
sizes: catalog,
});
```

No original throws `ResizeNoOriginalError`; every requested variant failing throws
`ResizeGenerateError`. `created` is only this call; `failed > 0` means a partial success.
`generate` also appends `created` onto `media.previews` (when persist is on) so a same-request
`resolve({ media })` sees them.

Listing queries:

```ts
import { resizeMediaPaths } from '@adaptivestone/framework-module-resize';
File.find().select(['mediaType', ...resizeMediaPaths]);
```

Hooks are typed — register at construction (`hooks:`) or later via `getResizer().hook(name, fn)`.
Expand Down Expand Up @@ -152,6 +174,7 @@ Observers (worker side): `onPreviewGenerated`, `afterTaskComplete`, `onTaskFaile
| boot throws `queue.lockTtlMs.worker … must be ≤ queue.leaseMs` | raise `queue.leaseMs` or lower `queue.lockTtlMs.worker` |
| previews never appear | the worker process isn't running, or `worker.enabled` is `false` in that process |
| first read of a new size is slow to fill | lazy mode working as designed — call `prewarm()` at upload if it matters |
| `resolve` `output` is `undefined` | no `formatPublicUrls` hook (or it threw) — map `decision` or use `formatPictureUrls` |

Config knobs: see the "Config reference" table in `README.md`; the defaults object is
`defaultResizeConfig` (main entry).
Loading
Loading