@pattern-stack/codegen 0.26.1 → 0.27.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 (50) hide show
  1. package/dist/{chunk-CKLM57IE.js → chunk-AC6T2JUX.js} +14 -14
  2. package/dist/{chunk-ENAR3F5S.js → chunk-HEOISQ6W.js} +6 -6
  3. package/dist/{chunk-HLURWFIT.js → chunk-IASPGFFK.js} +5 -5
  4. package/dist/{chunk-CDLWYZVQ.js → chunk-IN3EWFB4.js} +4 -4
  5. package/dist/{chunk-XNRKZCVH.js → chunk-IXAE6BN6.js} +2 -2
  6. package/dist/{chunk-7XDB4OMR.js → chunk-VCXOPBYY.js} +9 -9
  7. package/dist/{chunk-6M6LZEP6.js → chunk-VDVEGTSW.js} +4 -4
  8. package/dist/{chunk-QXYKV4CE.js → chunk-W4JYZSQK.js} +8 -8
  9. package/dist/{chunk-QO35B6BN.js → chunk-YIVQ7KLS.js} +4 -4
  10. package/dist/runtime/http/pagination.d.ts +151 -0
  11. package/dist/runtime/http/pagination.js +98 -0
  12. package/dist/runtime/http/pagination.js.map +1 -0
  13. package/dist/runtime/subsystems/auth/auth.module.js +1 -1
  14. package/dist/runtime/subsystems/auth/index.js +6 -6
  15. package/dist/runtime/subsystems/bridge/bridge.module.js +9 -9
  16. package/dist/runtime/subsystems/bridge/index.js +9 -9
  17. package/dist/runtime/subsystems/events/events.module.js +2 -2
  18. package/dist/runtime/subsystems/events/index.js +4 -4
  19. package/dist/runtime/subsystems/index.js +45 -45
  20. package/dist/runtime/subsystems/integration/execute-integration.use-case.js +2 -2
  21. package/dist/runtime/subsystems/integration/index.js +31 -31
  22. package/dist/runtime/subsystems/integration/integration-cursor-store.drizzle-backend.js +3 -3
  23. package/dist/runtime/subsystems/integration/integration-run-recorder.drizzle-backend.js +3 -3
  24. package/dist/runtime/subsystems/integration/integration.module.js +6 -6
  25. package/dist/runtime/subsystems/jobs/index.js +24 -24
  26. package/dist/runtime/subsystems/jobs/job-orchestrator.bullmq-backend.js +4 -4
  27. package/dist/runtime/subsystems/jobs/job-run-service.memory-backend.js +2 -2
  28. package/dist/runtime/subsystems/jobs/job-worker.module.js +8 -8
  29. package/dist/runtime/subsystems/jobs/jobs-domain.module.js +7 -7
  30. package/dist/runtime/subsystems/observability/index.js +3 -3
  31. package/dist/src/cli/index.js +195 -20
  32. package/dist/src/cli/index.js.map +1 -1
  33. package/dist/src/index.js +8 -8
  34. package/package.json +1 -1
  35. package/runtime/http/pagination.ts +233 -0
  36. package/templates/entity/new/clean-lite-ps/controller.ejs.t +27 -6
  37. package/templates/entity/new/clean-lite-ps/dto/list-query.ejs.t +22 -0
  38. package/templates/entity/new/clean-lite-ps/index.ejs.t +1 -0
  39. package/templates/entity/new/clean-lite-ps/prompt-extension.js +14 -0
  40. package/templates/entity/new/clean-lite-ps/use-cases/list.ejs.t +56 -3
  41. package/templates/entity/new/prompt.js +17 -0
  42. /package/dist/{chunk-CKLM57IE.js.map → chunk-AC6T2JUX.js.map} +0 -0
  43. /package/dist/{chunk-ENAR3F5S.js.map → chunk-HEOISQ6W.js.map} +0 -0
  44. /package/dist/{chunk-HLURWFIT.js.map → chunk-IASPGFFK.js.map} +0 -0
  45. /package/dist/{chunk-CDLWYZVQ.js.map → chunk-IN3EWFB4.js.map} +0 -0
  46. /package/dist/{chunk-XNRKZCVH.js.map → chunk-IXAE6BN6.js.map} +0 -0
  47. /package/dist/{chunk-7XDB4OMR.js.map → chunk-VCXOPBYY.js.map} +0 -0
  48. /package/dist/{chunk-6M6LZEP6.js.map → chunk-VDVEGTSW.js.map} +0 -0
  49. /package/dist/{chunk-QXYKV4CE.js.map → chunk-W4JYZSQK.js.map} +0 -0
  50. /package/dist/{chunk-QO35B6BN.js.map → chunk-YIVQ7KLS.js.map} +0 -0
package/dist/src/index.js CHANGED
@@ -47,14 +47,18 @@ import {
47
47
  validatePatternProject
48
48
  } from "../chunk-K4BQQ2NN.js";
49
49
  import "../chunk-KVOWSC5S.js";
50
+ import "../chunk-VCXOPBYY.js";
50
51
  import "../chunk-PRWIX6UW.js";
51
- import "../chunk-7XDB4OMR.js";
52
- import "../chunk-QXYKV4CE.js";
52
+ import "../chunk-W4JYZSQK.js";
53
53
  import "../chunk-EO2QPOKH.js";
54
- import "../chunk-HLURWFIT.js";
54
+ import "../chunk-SQDOBLBP.js";
55
+ import "../chunk-YIVQ7KLS.js";
56
+ import "../chunk-LG57S2SC.js";
57
+ import "../chunk-IASPGFFK.js";
58
+ import "../chunk-S5G3HO7N.js";
59
+ import "../chunk-MZ6GV4YF.js";
55
60
  import "../chunk-HNWZFNKP.js";
56
61
  import "../chunk-AHV4GDYM.js";
57
- import "../chunk-SQDOBLBP.js";
58
62
  import "../chunk-43SBT72G.js";
59
63
  import "../chunk-4MF3HKJA.js";
60
64
  import "../chunk-TIZXQU26.js";
@@ -62,10 +66,6 @@ import "../chunk-JEINYUJH.js";
62
66
  import "../chunk-5TK7MEN4.js";
63
67
  import "../chunk-4KNXX6TI.js";
64
68
  import "../chunk-3CJFPU6Q.js";
65
- import "../chunk-QO35B6BN.js";
66
- import "../chunk-MZ6GV4YF.js";
67
- import "../chunk-LG57S2SC.js";
68
- import "../chunk-S5G3HO7N.js";
69
69
  import "../chunk-U64T4YZE.js";
70
70
  import "../chunk-2E224ZSN.js";
71
71
  export {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pattern-stack/codegen",
3
- "version": "0.26.1",
3
+ "version": "0.27.0",
4
4
  "description": "Entity-driven code generation for full-stack TypeScript applications",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Pagination envelope + ListQuery contract for generated entity list endpoints
3
+ * (pagination-by-default).
4
+ *
5
+ * Every generated `GET /<entities>` returns a {@link Page} envelope instead of
6
+ * a bare `T[]`. The request shape is {@link ListQuery} (page/cursor/pageSize +
7
+ * default sort) merged with arbitrary where-filters at the controller.
8
+ *
9
+ * Mirrors the in-repo precedent: the jobs subsystem's `JobRunPage`
10
+ * (`{ items, nextCursor }`) and its opaque keyset cursor codec
11
+ * (`runtime/subsystems/jobs/job-run-keyset-cursor.ts`). The entity envelope
12
+ * EXTENDS that minimal shape with `page/pageCount/total/pageSize` so a numbered
13
+ * UI (jump-to-page) works while `nextCursor` stays contract-stable for the
14
+ * later keyset upgrade.
15
+ *
16
+ * ENGINE NOTE (v1): the list use-case fetches by OFFSET (page-based). The
17
+ * `nextCursor` is computed from the last row and emitted from day one so the
18
+ * contract never changes, but cursor-REQUEST honoring (keyset seek) is a
19
+ * DEFERRED seam — see the TODO in the generated list use-case / the repository
20
+ * query branch. `ListQuery.cursor` is ACCEPTED (no validation error) even
21
+ * though v1 ignores it for fetching.
22
+ */
23
+
24
+ import { z } from 'zod';
25
+
26
+ // ============================================================================
27
+ // Defaults + clamp
28
+ // ============================================================================
29
+
30
+ /** Default page size when `pageSize` is omitted. */
31
+ export const DEFAULT_PAGE_SIZE = 50;
32
+ /** Hard upper bound on page size to keep a single read bounded. */
33
+ export const MAX_PAGE_SIZE = 200;
34
+ /** Default page (1-based) when `page` is omitted. */
35
+ export const DEFAULT_PAGE = 1;
36
+ /** Default sort column. */
37
+ export const DEFAULT_SORT_BY = 'created_at';
38
+ /** Default sort direction. */
39
+ export const DEFAULT_SORT_ORDER = 'desc' as const;
40
+
41
+ /** Clamp a caller-supplied `pageSize` into `[1, MAX_PAGE_SIZE]`. */
42
+ export function clampPageSize(pageSize: number | undefined): number {
43
+ if (typeof pageSize !== 'number' || !Number.isFinite(pageSize)) {
44
+ return DEFAULT_PAGE_SIZE;
45
+ }
46
+ const floored = Math.floor(pageSize);
47
+ if (floored < 1) return 1;
48
+ if (floored > MAX_PAGE_SIZE) return MAX_PAGE_SIZE;
49
+ return floored;
50
+ }
51
+
52
+ /** Clamp a caller-supplied `page` to a 1-based integer (floor 1). */
53
+ export function clampPage(page: number | undefined): number {
54
+ if (typeof page !== 'number' || !Number.isFinite(page)) {
55
+ return DEFAULT_PAGE;
56
+ }
57
+ const floored = Math.floor(page);
58
+ return floored < 1 ? 1 : floored;
59
+ }
60
+
61
+ // ============================================================================
62
+ // ListQuery schema (request)
63
+ // ============================================================================
64
+
65
+ /**
66
+ * Zod schema for the universal list query string. All keys optional —
67
+ * pagination works fully UNFILTERED (the default mode). `pageSize` is clamped
68
+ * (default 50, max 200) and `sort_order` defaults to `desc`. Arbitrary where
69
+ * filters are NOT modeled here (they're parsed/passed through at the controller
70
+ * via `.passthrough()`); this schema owns ONLY the pagination + sort knobs so
71
+ * the defaults + clamp land in one place.
72
+ *
73
+ * `cursor` is accepted but v1 ignores it for fetching (offset engine) — the
74
+ * keyset seek is the deferred seam. Passing a `nextCursor` back never errors.
75
+ */
76
+ export const ListQuerySchema = z
77
+ .object({
78
+ page: z.coerce.number().int().optional(),
79
+ cursor: z.string().optional(),
80
+ pageSize: z.coerce.number().int().optional(),
81
+ sort_by: z.string().optional(),
82
+ sort_order: z.enum(['asc', 'desc']).optional(),
83
+ })
84
+ .passthrough();
85
+
86
+ /** Parsed list query (pre-clamp). Use {@link resolveListQuery} to normalize. */
87
+ export type ListQuery = z.infer<typeof ListQuerySchema>;
88
+
89
+ /**
90
+ * Normalized pagination options resolved from a raw {@link ListQuery}: clamped
91
+ * `page`/`pageSize`, computed `offset`, and a defaulted sort. The generated
92
+ * list use-case feeds these straight into `service.list({ limit, offset, ... })`.
93
+ */
94
+ export interface ResolvedListQuery {
95
+ page: number;
96
+ pageSize: number;
97
+ /** `(page - 1) * pageSize`. */
98
+ offset: number;
99
+ sortBy: string;
100
+ sortOrder: 'asc' | 'desc';
101
+ /** Opaque cursor as passed by the caller (v1: not honored — deferred seam). */
102
+ cursor?: string;
103
+ }
104
+
105
+ /**
106
+ * Resolve a raw list query into normalized, clamped pagination options.
107
+ * Defaults: page 1, pageSize 50 (max 200), sort `created_at desc`.
108
+ */
109
+ export function resolveListQuery(query: ListQuery | undefined): ResolvedListQuery {
110
+ const page = clampPage(query?.page);
111
+ const pageSize = clampPageSize(query?.pageSize);
112
+ return {
113
+ page,
114
+ pageSize,
115
+ offset: (page - 1) * pageSize,
116
+ sortBy: query?.sort_by ?? DEFAULT_SORT_BY,
117
+ sortOrder: query?.sort_order ?? DEFAULT_SORT_ORDER,
118
+ cursor: query?.cursor,
119
+ };
120
+ }
121
+
122
+ // ============================================================================
123
+ // Page envelope (response)
124
+ // ============================================================================
125
+
126
+ /**
127
+ * One page of a paginated list response. EXTENDS the jobs subsystem's minimal
128
+ * `{ items, nextCursor }` shape with the numbered-UI fields:
129
+ *
130
+ * - `page` — 1-based page number of THIS page.
131
+ * - `pageCount` — total number of pages (`ceil(total / pageSize)`, min 1).
132
+ * - `total` — total matching rows (reflects any where-filter).
133
+ * - `pageSize` — the (clamped) page size used.
134
+ * - `nextCursor` — opaque keyset cursor of the LAST row, or `null` on the
135
+ * last page / empty result. Contract-stable from day one;
136
+ * v1 emits it but fetches by offset.
137
+ */
138
+ export interface Page<T> {
139
+ items: T[];
140
+ page: number;
141
+ pageCount: number;
142
+ total: number;
143
+ pageSize: number;
144
+ nextCursor: string | null;
145
+ }
146
+
147
+ // ============================================================================
148
+ // Opaque cursor codec (encodes (createdAt, id))
149
+ // ============================================================================
150
+
151
+ /** Keyset tuple a {@link Page.nextCursor} encodes. */
152
+ export interface PageKeyset {
153
+ /** `created_at` of the last row on this page. */
154
+ createdAt: Date;
155
+ /** `id` (UUID) tie-break of the last row on this page. */
156
+ id: string;
157
+ }
158
+
159
+ /**
160
+ * Encode a `(createdAt, id)` keyset into an opaque, base64url cursor. The shape
161
+ * (a JSON tuple) is an implementation detail — never parse it outside this
162
+ * module. Mirrors `encodeKeysetCursor` in the jobs subsystem.
163
+ */
164
+ export function encodeCursor(keyset: PageKeyset): string {
165
+ const tuple = [keyset.createdAt.toISOString(), keyset.id];
166
+ return Buffer.from(JSON.stringify(tuple), 'utf8').toString('base64url');
167
+ }
168
+
169
+ /**
170
+ * Decode an opaque cursor back into its `(createdAt, id)` keyset. Returns
171
+ * `null` for a malformed cursor so a caller can treat garbage as "start from
172
+ * the beginning" rather than throw on user-supplied data.
173
+ */
174
+ export function decodeCursor(cursor: string): PageKeyset | null {
175
+ try {
176
+ const json = Buffer.from(cursor, 'base64url').toString('utf8');
177
+ const parsed = JSON.parse(json) as unknown;
178
+ if (!Array.isArray(parsed) || parsed.length !== 2) return null;
179
+ const [iso, id] = parsed;
180
+ if (typeof iso !== 'string' || typeof id !== 'string') return null;
181
+ const createdAt = new Date(iso);
182
+ if (Number.isNaN(createdAt.getTime())) return null;
183
+ return { createdAt, id };
184
+ } catch {
185
+ return null;
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Compute the `nextCursor` for a page of rows: the opaque cursor of the LAST
191
+ * row when more pages remain, else `null`. A row is expected to carry
192
+ * `createdAt: Date` and `id: string` (the default-sort keyset). Rows missing
193
+ * either field yield `null` (cursor not derivable — caller falls back to offset
194
+ * paging, which is the v1 engine anyway).
195
+ *
196
+ * @param rows the items on this page (already fetched, in sort order)
197
+ * @param hasMore whether more rows exist beyond this page
198
+ * (`offset + rows.length < total`)
199
+ */
200
+ export function computeNextCursor(
201
+ rows: ReadonlyArray<unknown>,
202
+ hasMore: boolean,
203
+ ): string | null {
204
+ if (!hasMore || rows.length === 0) return null;
205
+ const last = rows[rows.length - 1] as { createdAt?: unknown; id?: unknown };
206
+ const createdAt = last?.createdAt;
207
+ const id = last?.id;
208
+ if (!(createdAt instanceof Date) || typeof id !== 'string') return null;
209
+ return encodeCursor({ createdAt, id });
210
+ }
211
+
212
+ /**
213
+ * Assemble a {@link Page} envelope from a fetched page of rows + the total
214
+ * matching count + the resolved query. Computes `pageCount` and `nextCursor`.
215
+ * The single place the envelope shape is constructed, so the generated list
216
+ * use-case stays a thin call.
217
+ */
218
+ export function buildPage<T>(
219
+ items: T[],
220
+ total: number,
221
+ resolved: Pick<ResolvedListQuery, 'page' | 'pageSize' | 'offset'>,
222
+ ): Page<T> {
223
+ const pageCount = total === 0 ? 1 : Math.ceil(total / resolved.pageSize);
224
+ const hasMore = resolved.offset + items.length < total;
225
+ return {
226
+ items,
227
+ page: resolved.page,
228
+ pageCount,
229
+ total,
230
+ pageSize: resolved.pageSize,
231
+ nextCursor: computeNextCursor(items as ReadonlyArray<unknown>, hasMore),
232
+ };
233
+ }
@@ -4,8 +4,12 @@ skip_if: "<%= typeof clpOutputPaths === 'undefined' %>"
4
4
  force: true
5
5
  ---
6
6
  <%- typeof generatedBanner !== 'undefined' ? generatedBanner : '' %>
7
- import { Controller, Get<% if (generateWrites) { %>, Post, Patch, Delete, Body, Headers<% } %>, NotFoundException, Param, ParseUUIDPipe } from '@nestjs/common';
8
- import { ApiBearerAuth, <% if (generateWrites) { %>ApiBody, <% } %>ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger';
7
+ import { Controller, Get<% if (generateWrites) { %>, Post, Patch, Delete, Body, Headers<% } %>, NotFoundException, Param, ParseUUIDPipe, Query } from '@nestjs/common';
8
+ import { ApiBearerAuth, <% if (generateWrites) { %>ApiBody, <% } %>ApiOperation, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
9
+ import { ZodValidationPipe } from '<%= typeof zodValidationPipeImport !== 'undefined' ? zodValidationPipeImport : '@shared/pipes/zod-validation.pipe' %>';
10
+ import type { Page } from '<%= typeof paginationImport !== 'undefined' ? paginationImport : '@shared/http/pagination' %>';
11
+ import { <%= classNames.listQuerySchema %> } from './dto/list-<%= entityNamePlural %>.query';
12
+ import type { <%= classNames.listQueryDto %> } from './dto/list-<%= entityNamePlural %>.query';
9
13
  import { <%= classNames.findByIdUseCase %> } from './use-cases/find-<%= entityName %>-by-id.use-case';
10
14
  import { <%= classNames.listUseCase %> } from './use-cases/list-<%= entityNamePlural %>.use-case';
11
15
  <% if (eavEnabled) { -%>
@@ -13,7 +17,6 @@ import { <%= classNames.findByIdWithFieldsUseCase %> } from './use-cases/find-<%
13
17
  import { <%= classNames.listWithFieldsUseCase %> } from './use-cases/list-<%= entityNamePlural %>-with-fields.use-case';
14
18
  <% } -%>
15
19
  <% if (generateWrites) { -%>
16
- import { ZodValidationPipe } from '<%= typeof zodValidationPipeImport !== 'undefined' ? zodValidationPipeImport : '@shared/pipes/zod-validation.pipe' %>';
17
20
  import { <%= classNames.createUseCase %> } from './use-cases/create-<%= entityName %>.use-case';
18
21
  import { <%= classNames.updateUseCase %> } from './use-cases/update-<%= entityName %>.use-case';
19
22
  import { <%= classNames.deleteUseCase %> } from './use-cases/delete-<%= entityName %>.use-case';
@@ -47,14 +50,32 @@ export class <%= classNames.controller %> {
47
50
  ) {}
48
51
 
49
52
  @ApiOperation({ summary: 'List <%= entityNamePlural %>', operationId: 'list<%= classNames.entity %>s' })
53
+ @ApiQuery({ name: 'page', required: false, type: 'integer', description: '1-based page number (default 1).' })
54
+ @ApiQuery({ name: 'pageSize', required: false, type: 'integer', description: 'Page size (default 50, max 200).' })
55
+ @ApiQuery({ name: 'cursor', required: false, type: 'string', description: 'Opaque keyset cursor (accepted; v1 paginates by offset).' })
56
+ @ApiQuery({ name: 'sort_by', required: false, type: 'string', description: "Sort column (default 'created_at')." })
57
+ @ApiQuery({ name: 'sort_order', required: false, enum: ['asc', 'desc'], description: "Sort direction (default 'desc')." })
50
58
  @ApiResponse({
51
59
  status: 200,
52
- schema: { type: 'array', items: { $ref: '#/components/schemas/<%= classNames.outputDto %>' } },
60
+ schema: {
61
+ type: 'object',
62
+ properties: {
63
+ items: { type: 'array', items: { $ref: '#/components/schemas/<%= classNames.outputDto %>' } },
64
+ page: { type: 'integer' },
65
+ pageCount: { type: 'integer' },
66
+ total: { type: 'integer' },
67
+ pageSize: { type: 'integer' },
68
+ nextCursor: { type: 'string', nullable: true },
69
+ },
70
+ required: ['items', 'page', 'pageCount', 'total', 'pageSize', 'nextCursor'],
71
+ },
53
72
  })
54
73
  @ApiResponse({ status: 401, schema: { $ref: '#/components/schemas/ErrorResponseDto' } })
55
74
  @Get()
56
- async getAll(): Promise<<%= classNames.entity %>[]> {
57
- return this.listUseCase.execute();
75
+ async getAll(
76
+ @Query(new ZodValidationPipe(<%= classNames.listQuerySchema %>)) query: <%= classNames.listQueryDto %>,
77
+ ): Promise<Page<<%= classNames.entity %>>> {
78
+ return this.listUseCase.execute(query);
58
79
  }
59
80
  <% if (eavEnabled) { %>
60
81
  @ApiOperation({
@@ -0,0 +1,22 @@
1
+ ---
2
+ to: "<%= typeof clpOutputPaths !== 'undefined' ? clpOutputPaths.listQueryDto : null %>"
3
+ skip_if: "<%= typeof clpOutputPaths === 'undefined' %>"
4
+ force: true
5
+ ---
6
+ <%- typeof generatedBanner !== 'undefined' ? generatedBanner : '' %>
7
+ import { z } from 'zod';
8
+ import { ListQuerySchema } from '<%= typeof paginationImport !== 'undefined' ? paginationImport : '@shared/http/pagination' %>';
9
+
10
+ /**
11
+ * List query DTO for `GET /<%= entityNamePlural %>` (pagination-by-default).
12
+ *
13
+ * Re-exports the shared `ListQuerySchema` (page/cursor/pageSize/sort_by/
14
+ * sort_order + `.passthrough()` for arbitrary where-filters). pageSize is
15
+ * clamped (default 50, max 200) and the default sort is `created_at desc, id
16
+ * desc` — both applied in the list use-case via `resolveListQuery`. `cursor` is
17
+ * accepted but v1 ignores it for fetching (offset engine); the keyset seek is a
18
+ * deferred seam.
19
+ */
20
+ export const <%= classNames.listQuerySchema %> = ListQuerySchema;
21
+
22
+ export type <%= classNames.listQueryDto %> = z.infer<typeof <%= classNames.listQuerySchema %>>;
@@ -19,3 +19,4 @@ export type { <%= classNames.entity %> } from './<%= entityName %>.entity';
19
19
  export type { <%= classNames.createDto %> } from './dto/create-<%= entityName %>.dto';
20
20
  export type { <%= classNames.updateDto %> } from './dto/update-<%= entityName %>.dto';
21
21
  export type { <%= classNames.outputDto %> } from './dto/<%= entityName %>-output.dto';
22
+ export type { <%= classNames.listQueryDto %> } from './dto/list-<%= entityNamePlural %>.query';
@@ -1296,6 +1296,9 @@ export function buildCleanLitePsLocals(definition, baseLocals) {
1296
1296
  createDto: `${moduleGroupDir}/${entityNamePlural}/dto/create-${entityName}.dto.ts`,
1297
1297
  updateDto: `${moduleGroupDir}/${entityNamePlural}/dto/update-${entityName}.dto.ts`,
1298
1298
  outputDto: `${moduleGroupDir}/${entityNamePlural}/dto/${entityName}-output.dto.ts`,
1299
+ // Pagination-by-default: the universal list query DTO (page/cursor/pageSize
1300
+ // + sort). Always emitted — the list endpoint is unconditional.
1301
+ listQueryDto: `${moduleGroupDir}/${entityNamePlural}/dto/list-${entityNamePlural}.query.ts`,
1299
1302
  searchUseCase: searchQueryResolved
1300
1303
  ? `${moduleGroupDir}/${entityNamePlural}/use-cases/search-${entityNamePlural}.use-case.ts`
1301
1304
  : null,
@@ -1347,6 +1350,10 @@ export function buildCleanLitePsLocals(definition, baseLocals) {
1347
1350
  createSchema: `Create${entityNamePascal}Schema`,
1348
1351
  updateSchema: `Update${entityNamePascal}Schema`,
1349
1352
  outputSchema: `${entityNamePascal}OutputSchema`,
1353
+ // Pagination-by-default: list query DTO + schema (re-export of the shared
1354
+ // ListQuerySchema). Named per-entity so the controller import is unambiguous.
1355
+ listQueryDto: `List${entityNamePluralPascal}QueryDto`,
1356
+ listQuerySchema: `List${entityNamePluralPascal}QuerySchema`,
1350
1357
  };
1351
1358
 
1352
1359
  // Fields for create DTO: exclude id, behavior-managed fields, and FK fields
@@ -1413,6 +1420,12 @@ export function buildCleanLitePsLocals(definition, baseLocals) {
1413
1420
  baseLocals?.drizzleTokenImport ?? '@shared/constants/tokens';
1414
1421
  const drizzleTypeImport =
1415
1422
  baseLocals?.drizzleTypeImport ?? '@shared/types/drizzle';
1423
+ // Pagination contract (pagination-by-default). Package mode → the runtime
1424
+ // module `@pattern-stack/codegen/runtime/http/pagination`; vendored / default
1425
+ // → the consumer-owned `@shared/http/pagination`. Threaded from prompt.js;
1426
+ // unit tests that call buildCleanLitePsLocals directly get the @shared default.
1427
+ const paginationImport =
1428
+ baseLocals?.paginationImport ?? '@shared/http/pagination';
1416
1429
 
1417
1430
  return {
1418
1431
  // Clean-Lite-PS identity
@@ -1431,6 +1444,7 @@ export function buildCleanLitePsLocals(definition, baseLocals) {
1431
1444
  typedEventBusImport,
1432
1445
  drizzleTokenImport,
1433
1446
  drizzleTypeImport,
1447
+ paginationImport,
1434
1448
 
1435
1449
  // Pattern — registry-driven (ADR-031)
1436
1450
  patternName,
@@ -4,14 +4,67 @@ force: true
4
4
  ---
5
5
  <%- typeof generatedBanner !== 'undefined' ? generatedBanner : '' %>
6
6
  import { Injectable } from '@nestjs/common';
7
+ import { asc, desc, sql, type SQL } from 'drizzle-orm';
8
+ import { buildPage, resolveListQuery, type ListQuery, type Page } from '<%= typeof paginationImport !== 'undefined' ? paginationImport : '@shared/http/pagination' %>';
7
9
  import { <%= classNames.service %> } from '../<%= entityName %>.service';
8
- import type { <%= classNames.entity %> } from '../<%= entityName %>.entity';
10
+ import { <%= entityNamePlural %>, type <%= classNames.entity %> } from '../<%= entityName %>.entity';
9
11
 
12
+ /**
13
+ * Paginated list use-case for <%= entityNamePlural %> (pagination-by-default).
14
+ *
15
+ * Composes `service.list({ where, limit, offset, orderBy })` + `service.count(where)`
16
+ * into a `Page<<%- classNames.entity %>>` envelope. Defaults: page 1,
17
+ * pageSize 50 (max 200), sort `created_at desc, id desc`. `total`/`pageCount`
18
+ * reflect the (optionally filtered) set, so pagination composes with where
19
+ * filters orthogonally — it works fully unfiltered too.
20
+ *
21
+ * v1 ENGINE = OFFSET. `nextCursor` is computed from the last row and emitted
22
+ * (contract-stable), but cursor-REQUEST honoring (keyset seek) is DEFERRED:
23
+ * `resolved.cursor` is accepted and ignored here. The keyset swap belongs in
24
+ * the marked seam below — fetch by `WHERE (created_at, id) < decodeCursor(cursor)`
25
+ * instead of `offset` — and is otherwise invisible to the controller/UI.
26
+ */
10
27
  @Injectable()
11
28
  export class <%= classNames.listUseCase %> {
12
29
  constructor(private readonly service: <%= classNames.service %>) {}
13
30
 
14
- async execute(): Promise<<%= classNames.entity %>[]> {
15
- return this.service.list();
31
+ async execute(query?: ListQuery): Promise<Page<<%= classNames.entity %>>> {
32
+ const resolved = resolveListQuery(query);
33
+
34
+ // Default sort: `created_at desc, id desc` (id is the stable keyset
35
+ // tie-break). A caller `sort_by` that names a real column is honored in the
36
+ // requested direction with the id tie-break appended; an unknown column
37
+ // falls back to the default. Composed as a single SQL fragment because the
38
+ // base repository's `orderBy` takes one expression.
39
+ const dir = resolved.sortOrder === 'asc' ? asc : desc;
40
+ const col = (<%= entityNamePlural %> as unknown as Record<string, unknown>)[
41
+ resolved.sortBy.replace(/_([a-z])/g, (_m: string, c: string) => c.toUpperCase())
42
+ ];
43
+ const orderBy: SQL =
44
+ col === undefined
45
+ ? sql`${desc(<%= entityNamePlural %>.createdAt)}, ${desc(<%= entityNamePlural %>.id)}`
46
+ : sql`${dir(col as never)}, ${desc(<%= entityNamePlural %>.id)}`;
47
+
48
+ // Arbitrary where-filters are NOT modeled in v1 (the ListQuery owns only
49
+ // pagination + sort); `where` stays undefined so the list is unfiltered by
50
+ // default. A future filter seam ANDs predicates here and passes the same
51
+ // `where` to both `list` and `count` so `total`/`pageCount` stay accurate.
52
+ const where: SQL | undefined = undefined;
53
+
54
+ // KEYSET SEAM (deferred — v1 fetches by offset). When the keyset upgrade
55
+ // lands, branch here on `resolved.cursor`: decode it and fetch by
56
+ // `WHERE (created_at, id) < (cursorCreatedAt, cursorId)` LIMIT pageSize,
57
+ // dropping the offset. The envelope + nextCursor below are unchanged.
58
+ const [items, total] = await Promise.all([
59
+ this.service.list({
60
+ where,
61
+ limit: resolved.pageSize,
62
+ offset: resolved.offset,
63
+ orderBy,
64
+ }),
65
+ this.service.count(where),
66
+ ]);
67
+
68
+ return buildPage(items, total, resolved);
16
69
  }
17
70
  }
@@ -1380,6 +1380,22 @@ export default {
1380
1380
  const typedEventBusImport = subsystemsImport(runtimeMode, 'events');
1381
1381
  const drizzleTokenImport = runtimeImport(runtimeMode, 'constants/tokens');
1382
1382
  const drizzleTypeImport = runtimeImport(runtimeMode, 'types/drizzle');
1383
+ // Pagination contract (pagination-by-default). ASYMMETRIC by mode:
1384
+ // - package → `@pattern-stack/codegen/runtime/http/pagination` (Page<T>,
1385
+ // ListQuerySchema, resolveListQuery, buildPage, cursor codec) — the
1386
+ // package-published runtime; swe-brain consumes this green.
1387
+ // - vendored → `@shared/http/page` (vendored to `src/shared/http/page.ts`
1388
+ // by project init's VENDORED_RUNTIME_FILES). DISTINCT from the consumer's
1389
+ // OPTIONAL `@shared/http/pagination` search contract ({items,total,limit,
1390
+ // offset}) — vendoring the Page<T> envelope to `/pagination` would
1391
+ // clobber it, so the list envelope lives at `/page`.
1392
+ // Unlike most @shared/http/* files (which the package never owns), THIS one
1393
+ // IS package-published — the list endpoint is unconditional, so its contract
1394
+ // must ship with codegen (package mode) and be vendored (vendored mode).
1395
+ const paginationImport =
1396
+ runtimeMode === 'vendored'
1397
+ ? '@shared/http/page'
1398
+ : runtimeImport(runtimeMode, 'http/pagination');
1383
1399
  // Integration subsystem barrel (ADR-033.1 inline-sync `integration-source`
1384
1400
  // module — emitted only for entities with an inline `detection:` block).
1385
1401
  const integrationSubsystemImport = subsystemsImport(runtimeMode, 'integration');
@@ -1602,6 +1618,7 @@ export default {
1602
1618
  typedEventBusImport,
1603
1619
  drizzleTokenImport,
1604
1620
  drizzleTypeImport,
1621
+ paginationImport,
1605
1622
  integrationSubsystemImport,
1606
1623
  withAnalyticsImport,
1607
1624
  integrationUpsertConfigImport,