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
1 change: 1 addition & 0 deletions .changelog/NEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@

## Internal

- **[issue-2840] Paginate album/artist/author list endpoints** — `GET /api/albums`, `/api/artists`, and `/api/authors` now accept `limit`/`offset` via the shared `parsePagination`/`paginateArray` helpers, returning the same `{ items, total, limit, offset }` envelope the notes/mood-board/loras list endpoints use when a pagination param is present. Unparameterized requests still return the full array, so existing callers are unaffected.
- **[issue-2832] Split the boot-schema DDL into per-domain modules** — the ~1265-line inline `CREATE TABLE`/`CREATE INDEX`/trigger block in `ensureSchemaImpl()` (`server/lib/db.js`) is extracted into per-domain modules under `server/lib/db/schema/` (catalog, media, universes, writers-room, pipeline, privacy, tribe, audit, …), each exporting a statement array; `ensureSchemaImpl` is now a thin composer. Statement text and order are byte-identical, so the composed schema is unchanged.
- **[issue-2834] Split the ~1990-line `VideoGen.jsx` into hooks + subcomponents** — the client-side batch-queue orchestration moves into `useVideoGenQueue`, the pure model-memory / FFLF frame-budget / mode-compat helpers into `lib/videoGenParams.js`, and four presentational blocks into `components/videoGen/` (`RuntimeFingerprint`, `ModelRepairBanner`, `VideoPreviewPanel`, `VideoGenGallery`). The inline runtime-status `fetch()` now routes through a `getVideoGenRuntimeStatus` service wrapper, and the Seed input gains a proper `<label htmlFor>`/`id` pairing. Behavior and deep-link routing are unchanged.
- **[issue-2835] Split the ~2875-line `ArcCanvas.jsx` into per-component files** — each of the 32 subcomponents/helpers (ArcHeader, EditorialRoadmapPanel, SeasonRow, IssueRow, VolumeCoversPanel, …) now lives in its own file under `client/src/components/pipeline/arcCanvas/`, with the shared severity-color map in `arcCanvas/shared.js`. `ArcCanvas.jsx` (default export) is now a thin 122-line composer and still re-exports `ArcRoadmapChart` so its public import path is unchanged. Pure mechanical, behavior-preserving refactor — no rendered-output change.
14 changes: 10 additions & 4 deletions server/routes/albums.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/**
* Music album routes.
*
* GET /api/albums → Album[] (live, sorted by title)
* GET /api/albums → Album[] (live, sorted by title; `?limit`/`?offset`
* switches to the bounded envelope)
* POST /api/albums → Album
* GET /api/albums/:id → Album
* PATCH /api/albums/:id → Album
Expand All @@ -14,7 +15,7 @@
import { Router } from 'express';
import { z } from 'zod';
import { asyncHandler, ServerError } from '../lib/errorHandler.js';
import { validateRequest } from '../lib/validation.js';
import { validateRequest, isPaginationRequested, paginateArray } from '../lib/validation.js';
import * as albums from '../services/albums/index.js';
import * as tracks from '../services/tracks/index.js';

Expand Down Expand Up @@ -104,8 +105,13 @@ async function reconcileAlbumMembership(album) {
}));
}

router.get('/', asyncHandler(async (_req, res) => {
res.json(await albums.listAlbums());
// Backward-compatible by default: returns the full albums array. When a client
// passes `limit`/`offset`, the response becomes the bounded
// `{ items, total, limit, offset }` envelope every paginated PortOS list shares.
router.get('/', asyncHandler(async (req, res) => {
const list = await albums.listAlbums();
if (!isPaginationRequested(req.query)) return res.json(list);
res.json(paginateArray(list, req.query, { defaultLimit: 50, maxLimit: 500 }));
}));

router.post('/', asyncHandler(async (req, res) => {
Expand Down
26 changes: 26 additions & 0 deletions server/routes/albums.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,32 @@ describe('albums routes', () => {
expect(r.body).toEqual([{ id: 'album-1', title: 'Debut' }]);
});

// Regression guard: every client caller (AlbumsManager, TracksManager) calls
// listAlbums() with no query params. If this ever returns an envelope instead
// of a bare array, those lists silently render empty.
it('GET / without pagination params returns the unbounded bare array', async () => {
albums.listAlbums.mockResolvedValueOnce(
Array.from({ length: 120 }, (_, i) => ({ id: `album-${i}`, title: `T${i}` }))
);
const r = await request(app).get('/api/albums');
expect(r.status).toBe(200);
expect(Array.isArray(r.body)).toBe(true);
expect(r.body).toHaveLength(120);
});

it('GET / returns a bounded envelope when pagination is requested', async () => {
albums.listAlbums.mockResolvedValueOnce(
Array.from({ length: 5 }, (_, i) => ({ id: `album-${i}`, title: `T${i}` }))
);
const r = await request(app).get('/api/albums?limit=2&offset=1');
expect(r.status).toBe(200);
expect(r.body.items).toHaveLength(2);
expect(r.body.items[0].id).toBe('album-1');
expect(r.body.total).toBe(5);
expect(r.body.limit).toBe(2);
expect(r.body.offset).toBe(1);
});

it('POST / creates an album', async () => {
const r = await request(app).post('/api/albums').send({ title: 'Debut', genre: 'folk' });
expect(r.status).toBe(201);
Expand Down
14 changes: 10 additions & 4 deletions server/routes/artists.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/**
* Music artist routes.
*
* GET /api/artists → Artist[] (live, sorted by name)
* GET /api/artists → Artist[] (live, sorted by name; `?limit`/`?offset`
* switches to the bounded envelope)
* POST /api/artists → Artist
* GET /api/artists/:id → Artist
* PATCH /api/artists/:id → Artist
Expand All @@ -15,7 +16,7 @@
import { Router } from 'express';
import { z } from 'zod';
import { asyncHandler, ServerError } from '../lib/errorHandler.js';
import { validateRequest } from '../lib/validation.js';
import { validateRequest, isPaginationRequested, paginateArray } from '../lib/validation.js';
import * as artists from '../services/artists/index.js';

const router = Router();
Expand Down Expand Up @@ -48,8 +49,13 @@ const patchSchema = z.object({
portraitImageUrl: portraitImageUrlField.optional(),
}).refine((p) => Object.keys(p).length > 0, { message: 'patch must include at least one field' });

router.get('/', asyncHandler(async (_req, res) => {
res.json(await artists.listArtists());
// Backward-compatible by default: returns the full artists array. When a client
// passes `limit`/`offset`, the response becomes the bounded
// `{ items, total, limit, offset }` envelope every paginated PortOS list shares.
router.get('/', asyncHandler(async (req, res) => {
const list = await artists.listArtists();
if (!isPaginationRequested(req.query)) return res.json(list);
res.json(paginateArray(list, req.query, { defaultLimit: 50, maxLimit: 500 }));
}));

router.post('/', asyncHandler(async (req, res) => {
Expand Down
26 changes: 26 additions & 0 deletions server/routes/artists.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,32 @@ describe('artists routes', () => {
expect(r.body).toEqual([{ id: 'artist-1', name: 'Nova' }]);
});

// Regression guard: every client caller (ArtistsManager, ArtistPicker) calls
// listArtists() with no query params. If this ever returns an envelope instead
// of a bare array, those lists silently render empty.
it('GET / without pagination params returns the unbounded bare array', async () => {
artists.listArtists.mockResolvedValueOnce(
Array.from({ length: 120 }, (_, i) => ({ id: `artist-${i}`, name: `N${i}` }))
);
const r = await request(app).get('/api/artists');
expect(r.status).toBe(200);
expect(Array.isArray(r.body)).toBe(true);
expect(r.body).toHaveLength(120);
});

it('GET / returns a bounded envelope when pagination is requested', async () => {
artists.listArtists.mockResolvedValueOnce(
Array.from({ length: 5 }, (_, i) => ({ id: `artist-${i}`, name: `N${i}` }))
);
const r = await request(app).get('/api/artists?limit=2&offset=1');
expect(r.status).toBe(200);
expect(r.body.items).toHaveLength(2);
expect(r.body.items[0].id).toBe('artist-1');
expect(r.body.total).toBe(5);
expect(r.body.limit).toBe(2);
expect(r.body.offset).toBe(1);
});

it('POST / creates an artist', async () => {
const r = await request(app).post('/api/artists').send({ name: 'Nova', genre: 'indie folk' });
expect(r.status).toBe(201);
Expand Down
14 changes: 10 additions & 4 deletions server/routes/authors.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/**
* Author persona routes.
*
* GET /api/authors → Author[] (live, sorted by name)
* GET /api/authors → Author[] (live, sorted by name; `?limit`/`?offset`
* switches to the bounded envelope)
* POST /api/authors → Author
* GET /api/authors/:id → Author
* PATCH /api/authors/:id → Author
Expand All @@ -11,7 +12,7 @@
import { Router } from 'express';
import { z } from 'zod';
import { asyncHandler, ServerError } from '../lib/errorHandler.js';
import { validateRequest } from '../lib/validation.js';
import { validateRequest, isPaginationRequested, paginateArray } from '../lib/validation.js';
import * as authors from '../services/authors/index.js';

const router = Router();
Expand Down Expand Up @@ -41,8 +42,13 @@ const patchSchema = z.object({
headshotImageUrl: headshotImageUrlField.optional(),
}).refine((p) => Object.keys(p).length > 0, { message: 'patch must include at least one field' });

router.get('/', asyncHandler(async (_req, res) => {
res.json(await authors.listAuthors());
// Backward-compatible by default: returns the full authors array. When a client
// passes `limit`/`offset`, the response becomes the bounded
// `{ items, total, limit, offset }` envelope every paginated PortOS list shares.
router.get('/', asyncHandler(async (req, res) => {
const list = await authors.listAuthors();
if (!isPaginationRequested(req.query)) return res.json(list);
res.json(paginateArray(list, req.query, { defaultLimit: 50, maxLimit: 500 }));
}));

router.post('/', asyncHandler(async (req, res) => {
Expand Down
26 changes: 26 additions & 0 deletions server/routes/authors.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,32 @@ describe('authors routes', () => {
expect(r.body).toEqual([{ id: 'auth-1', name: 'Jane' }]);
});

// Regression guard: every client caller (Authors page, AuthorPicker) calls
// listAuthors() with no query params. If this ever returns an envelope instead
// of a bare array, those lists silently render empty.
it('GET / without pagination params returns the unbounded bare array', async () => {
authors.listAuthors.mockResolvedValueOnce(
Array.from({ length: 120 }, (_, i) => ({ id: `auth-${i}`, name: `A${i}` }))
);
const r = await request(app).get('/api/authors');
expect(r.status).toBe(200);
expect(Array.isArray(r.body)).toBe(true);
expect(r.body).toHaveLength(120);
});

it('GET / returns a bounded envelope when pagination is requested', async () => {
authors.listAuthors.mockResolvedValueOnce(
Array.from({ length: 5 }, (_, i) => ({ id: `auth-${i}`, name: `A${i}` }))
);
const r = await request(app).get('/api/authors?limit=2&offset=1');
expect(r.status).toBe(200);
expect(r.body.items).toHaveLength(2);
expect(r.body.items[0].id).toBe('auth-1');
expect(r.body.total).toBe(5);
expect(r.body.limit).toBe(2);
expect(r.body.offset).toBe(1);
});

it('POST / creates an author', async () => {
const r = await request(app).post('/api/authors').send({ name: 'Jane', bio: 'blurb' });
expect(r.status).toBe(201);
Expand Down