tina4-nodejs 3.13.131 → 3.13.133

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.
@@ -157,16 +157,85 @@ function sanitizeSecurity(
157
157
  });
158
158
  }
159
159
 
160
+ /**
161
+ * Shared, mutable state threaded through the per-route builders. Bundled so
162
+ * `generate()` reads as a flat orchestrator and each helper keeps a single
163
+ * responsibility (parity with the Python/PHP decomposition of `generate`).
164
+ */
165
+ interface BuildContext {
166
+ models: ModelDefinition[];
167
+ tableToSchema: Map<string, string>;
168
+ schemes: Record<string, Record<string, unknown>>;
169
+ defaultScheme: string;
170
+ includePrefixes: string[];
171
+ excludePrefixes: string[];
172
+ refSchemas: Set<string>;
173
+ usedTags: string[];
174
+ seenIds: Set<string>;
175
+ }
176
+
160
177
  export function generate(
161
178
  routes: RouteDefinition[],
162
179
  models: ModelDefinition[] = []
163
180
  ): OpenAPISpec {
181
+ const schemes = resolveSecuritySchemes();
182
+ const spec: OpenAPISpec = {
183
+ openapi: resolveOpenApiVersion(),
184
+ info: buildInfo(),
185
+ servers: resolveServers(),
186
+ paths: {},
187
+ components: {
188
+ schemas: {},
189
+ // Configurable security schemes (v3.13.42): bearerFormat via env, optional
190
+ // apiKey scheme, plus any programmatically-registered schemes (which may
191
+ // override bearerAuth — e.g. an oauth2 scheme with scopes).
192
+ securitySchemes: schemes,
193
+ },
194
+ };
195
+
196
+ // Generate schemas from models, keyed by the model CLASS name ('Item'), which
197
+ // is the type name a generated client wants — never the tableName ('items').
198
+ // `tableToSchema` maps a path-inferred tableName back to that schema key so a
199
+ // POST/PUT request body $ref resolves to the same 'Item' entry.
200
+ const tableToSchema = new Map<string, string>();
201
+ buildComponentSchemas(models, spec, tableToSchema);
202
+
203
+ const ctx: BuildContext = {
204
+ models,
205
+ tableToSchema,
206
+ schemes,
207
+ // Default scheme secured routes use when no explicit meta.security is set.
208
+ defaultScheme: process.env.TINA4_SWAGGER_DEFAULT_SCHEME ?? "bearerAuth",
209
+ // Path filters (comma-separated raw-path prefixes).
210
+ includePrefixes: csv(process.env.TINA4_SWAGGER_INCLUDE),
211
+ excludePrefixes: csv(process.env.TINA4_SWAGGER_EXCLUDE),
212
+ // Reusable custom schemas referenced by routes via meta.requestSchema/responseSchemas.
213
+ refSchemas: new Set<string>(),
214
+ usedTags: [],
215
+ seenIds: new Set<string>(),
216
+ };
217
+
218
+ // Generate paths from routes
219
+ for (const route of routes) {
220
+ buildOperation(route, spec, ctx);
221
+ }
222
+
223
+ buildRefSchemas(spec, ctx.refSchemas);
224
+ buildTags(spec, ctx.usedTags);
225
+
226
+ return spec;
227
+ }
228
+
229
+ /**
230
+ * The `info` block: title/version/description, plus optional contact and
231
+ * license. The app's version defaults to 1.0.0 — NOT the framework's (Node
232
+ * shipped 0.0.1) — and description to the empty string, not a canned sentence;
233
+ * both are the settled cross-framework defaults (parity with the Python master),
234
+ * with TINA4_SWAGGER_VERSION / _DESCRIPTION still overriding.
235
+ */
236
+ function buildInfo(): OpenAPISpecInfo {
164
237
  const info: OpenAPISpecInfo = {
165
238
  title: process.env.TINA4_SWAGGER_TITLE ?? "Tina4 API",
166
- // The app's version, defaulting to 1.0.0 — NOT the framework's (Node shipped
167
- // 0.0.1). description defaults to the empty string, not a canned sentence.
168
- // Both are the settled cross-framework defaults (parity with the Python
169
- // master); TINA4_SWAGGER_VERSION / _DESCRIPTION still override.
170
239
  version: process.env.TINA4_SWAGGER_VERSION ?? "1.0.0",
171
240
  description: process.env.TINA4_SWAGGER_DESCRIPTION ?? "",
172
241
  };
@@ -190,183 +259,220 @@ export function generate(
190
259
  info.license = url ? { name, url } : { name };
191
260
  }
192
261
 
193
- const schemes = resolveSecuritySchemes();
194
- const spec: OpenAPISpec = {
195
- openapi: resolveOpenApiVersion(),
196
- info,
197
- servers: resolveServers(),
198
- paths: {},
199
- components: {
200
- schemas: {},
201
- // Configurable security schemes (v3.13.42): bearerFormat via env, optional
202
- // apiKey scheme, plus any programmatically-registered schemes (which may
203
- // override bearerAuth — e.g. an oauth2 scheme with scopes).
204
- securitySchemes: schemes,
205
- },
206
- };
207
-
208
- // Default scheme secured routes use when no explicit meta.security is set.
209
- const defaultScheme = process.env.TINA4_SWAGGER_DEFAULT_SCHEME ?? "bearerAuth";
210
-
211
- // Path filters (comma-separated raw-path prefixes).
212
- const includePrefixes = csv(process.env.TINA4_SWAGGER_INCLUDE);
213
- const excludePrefixes = csv(process.env.TINA4_SWAGGER_EXCLUDE);
214
-
215
- // Reusable custom schemas referenced by routes via meta.requestSchema/responseSchemas.
216
- const refSchemas = new Set<string>();
262
+ return info;
263
+ }
217
264
 
218
- // Generate schemas from models, keyed by the model CLASS name ('Item'), which
219
- // is the type name a generated client wants — never the tableName ('items').
220
- // `tableToSchema` maps a path-inferred tableName back to that schema key so a
221
- // POST/PUT request body $ref resolves to the same 'Item' entry.
222
- const tableToSchema = new Map<string, string>();
265
+ /**
266
+ * Populate `components.schemas` from ORM models (keyed by the class name) and
267
+ * record the tableName -> schema-key map used to resolve request-body $refs.
268
+ */
269
+ function buildComponentSchemas(
270
+ models: ModelDefinition[],
271
+ spec: OpenAPISpec,
272
+ tableToSchema: Map<string, string>
273
+ ): void {
223
274
  for (const model of models) {
224
275
  const schemaKey = schemaNameForModel(model);
225
276
  tableToSchema.set(model.tableName, schemaKey);
226
277
  spec.components!.schemas![schemaKey] = modelToSchema(model);
227
278
  }
279
+ }
228
280
 
229
- const usedTags: string[] = [];
230
- const seenIds = new Set<string>();
281
+ /**
282
+ * Build ONE path operation for a route and attach it to `spec.paths`, honouring
283
+ * the include/exclude filter and delegating each part of the operation to a
284
+ * focused sub-builder (parameters, request body, response schemas, security).
285
+ */
286
+ function buildOperation(route: RouteDefinition, spec: OpenAPISpec, ctx: BuildContext): void {
287
+ if (!isIncludedPath(route.pattern, ctx.includePrefixes, ctx.excludePrefixes)) return;
288
+ const openApiPath = patternToOpenAPI(route.pattern);
289
+ const method = route.method.toLowerCase();
231
290
 
232
- // Generate paths from routes
233
- for (const route of routes) {
234
- if (!isIncludedPath(route.pattern, includePrefixes, excludePrefixes)) continue;
235
- const openApiPath = patternToOpenAPI(route.pattern);
236
- const method = route.method.toLowerCase();
291
+ if (!spec.paths[openApiPath]) {
292
+ spec.paths[openApiPath] = {};
293
+ }
237
294
 
238
- if (!spec.paths[openApiPath]) {
239
- spec.paths[openApiPath] = {};
240
- }
295
+ const tags = route.meta?.tags ?? inferTags(route.pattern);
296
+ for (const t of tags) {
297
+ if (!ctx.usedTags.includes(t)) ctx.usedTags.push(t);
298
+ }
241
299
 
242
- const tags = route.meta?.tags ?? inferTags(route.pattern);
243
- for (const t of tags) {
244
- if (!usedTags.includes(t)) usedTags.push(t);
245
- }
300
+ const operation: Record<string, unknown> = {
301
+ operationId: uniqueOperationId(method, openApiPath, ctx.seenIds),
302
+ summary: route.meta?.summary ?? `${route.method} ${route.pattern}`,
303
+ tags,
304
+ responses: route.meta?.responses ?? {
305
+ "200": { description: "Successful response" },
306
+ },
307
+ };
246
308
 
247
- const operation: Record<string, unknown> = {
248
- operationId: uniqueOperationId(method, openApiPath, seenIds),
249
- summary: route.meta?.summary ?? `${route.method} ${route.pattern}`,
250
- tags,
251
- responses: route.meta?.responses ?? {
252
- "200": { description: "Successful response" },
253
- },
254
- };
309
+ if (route.meta?.description) operation.description = route.meta.description;
310
+ if (route.meta?.deprecated) operation.deprecated = true;
255
311
 
256
- if (route.meta?.description) operation.description = route.meta.description;
257
- if (route.meta?.deprecated) operation.deprecated = true;
258
-
259
- // Add path parameters. Each typed token maps to its JSON Schema fragment
260
- // (int -> integer, uuid -> string+format, slug -> string+pattern, ...) via
261
- // PARAM_TYPE_SCHEMA below, mirroring the Python master's _PARAM_TYPE_SCHEMA
262
- // and the router's accepted token set. An unknown token degrades to string
263
- // rather than dropping the parameter, and the token never reaches the key.
264
- const pathParams = extractPathParams(route.pattern);
265
- if (pathParams.length > 0) {
266
- operation.parameters = pathParams.map(({ name, schema }) => ({
267
- name,
268
- in: "path",
269
- required: true,
270
- schema,
271
- }));
272
- }
312
+ const parameters = operationParameters(route, method, ctx.models);
313
+ if (parameters.length > 0) operation.parameters = parameters;
273
314
 
274
- // Add query parameters for GET list endpoints
275
- if (method === "get" && !route.pattern.includes("[id]") && !route.pattern.includes("[...")) {
276
- const modelName = inferModelFromPath(route.pattern);
277
- if (modelName && models.some((m) => m.tableName === modelName)) {
278
- operation.parameters = [
279
- ...(operation.parameters as unknown[] ?? []),
280
- { name: "page", in: "query", schema: { type: "integer", default: 1 } },
281
- { name: "limit", in: "query", schema: { type: "integer", default: 20 } },
282
- { name: "sort", in: "query", schema: { type: "string" }, description: "Sort fields (prefix with - for descending)" },
283
- ];
284
- }
315
+ operationRequestBody(route, method, operation, ctx);
316
+ operationResponseSchemas(route, operation, ctx.refSchemas);
317
+ operationSecurity(route, method, operation, ctx.schemes, ctx.defaultScheme);
318
+
319
+ spec.paths[openApiPath][method] = operation;
320
+ }
321
+
322
+ /**
323
+ * The operation's parameters: path parameters (each typed token mapped to its
324
+ * JSON Schema fragment via PARAM_TYPE_SCHEMA — int -> integer, uuid ->
325
+ * string+format, slug -> string+pattern, ...; an unknown token degrades to
326
+ * string rather than dropping the parameter), followed by the page/limit/sort
327
+ * query parameters a GET list endpoint over a known model gets.
328
+ */
329
+ function operationParameters(
330
+ route: RouteDefinition,
331
+ method: string,
332
+ models: ModelDefinition[]
333
+ ): Array<Record<string, unknown>> {
334
+ let parameters: Array<Record<string, unknown>> = [];
335
+
336
+ const pathParams = extractPathParams(route.pattern);
337
+ if (pathParams.length > 0) {
338
+ parameters = pathParams.map(({ name, schema }) => ({
339
+ name,
340
+ in: "path",
341
+ required: true,
342
+ schema,
343
+ }));
344
+ }
345
+
346
+ // Add query parameters for GET list endpoints
347
+ if (method === "get" && !route.pattern.includes("[id]") && !route.pattern.includes("[...")) {
348
+ const modelName = inferModelFromPath(route.pattern);
349
+ if (modelName && models.some((m) => m.tableName === modelName)) {
350
+ parameters = [
351
+ ...parameters,
352
+ { name: "page", in: "query", schema: { type: "integer", default: 1 } },
353
+ { name: "limit", in: "query", schema: { type: "integer", default: 20 } },
354
+ { name: "sort", in: "query", schema: { type: "string" }, description: "Sort fields (prefix with - for descending)" },
355
+ ];
285
356
  }
357
+ }
286
358
 
287
- // Request body — a registered custom schema $ref (meta.requestSchema) wins,
288
- // else the inferred-from-model body (POST/PUT to a resource), else an
289
- // example-only body.
290
- const reqSchemaRef = parseRequestSchema(route.meta?.requestSchema);
291
- if (reqSchemaRef && (method === "post" || method === "put" || method === "patch")) {
292
- refSchemas.add(reqSchemaRef.name);
293
- const media: Record<string, unknown> = {
294
- schema: { $ref: `#/components/schemas/${reqSchemaRef.name}` },
295
- };
296
- if (route.meta?.example !== undefined) media.example = route.meta.example;
359
+ return parameters;
360
+ }
361
+
362
+ /** A media-type object: the schema, plus meta.example only when one was given.
363
+ * Key order (schema, then example) matches the emitted document. */
364
+ function mediaWithExample(schema: unknown, example: unknown): Record<string, unknown> {
365
+ const media: Record<string, unknown> = { schema };
366
+ if (example !== undefined) media.example = example;
367
+ return media;
368
+ }
369
+
370
+ /**
371
+ * Attach the request body (and, for a path-inferred model write, its 200
372
+ * response schema). A registered custom schema $ref (meta.requestSchema) wins,
373
+ * else the inferred-from-model body (POST/PUT to a resource), else an
374
+ * example-only body.
375
+ */
376
+ function operationRequestBody(
377
+ route: RouteDefinition,
378
+ method: string,
379
+ operation: Record<string, unknown>,
380
+ ctx: BuildContext
381
+ ): void {
382
+ const reqSchemaRef = parseRequestSchema(route.meta?.requestSchema);
383
+ if (reqSchemaRef && (method === "post" || method === "put" || method === "patch")) {
384
+ ctx.refSchemas.add(reqSchemaRef.name);
385
+ const media = mediaWithExample({ $ref: `#/components/schemas/${reqSchemaRef.name}` }, route.meta?.example);
386
+ operation.requestBody = {
387
+ content: { [reqSchemaRef.contentType]: media },
388
+ };
389
+ } else if (method === "post" || method === "put") {
390
+ const modelName = inferModelFromPath(route.pattern);
391
+ const schemaKey = modelName ? ctx.tableToSchema.get(modelName) : undefined;
392
+ if (schemaKey) {
393
+ const sref = `#/components/schemas/${schemaKey}`;
297
394
  operation.requestBody = {
298
- content: { [reqSchemaRef.contentType]: media },
395
+ required: true,
396
+ content: { "application/json": mediaWithExample({ $ref: sref }, route.meta?.example) },
299
397
  };
300
- } else if (method === "post" || method === "put") {
301
- const modelName = inferModelFromPath(route.pattern);
302
- const schemaKey = modelName ? tableToSchema.get(modelName) : undefined;
303
- if (schemaKey) {
304
- const sref = `#/components/schemas/${schemaKey}`;
305
- const media: Record<string, unknown> = { schema: { $ref: sref } };
306
- if (route.meta?.example !== undefined) media.example = route.meta.example;
307
- operation.requestBody = {
308
- required: true,
309
- content: { "application/json": media },
310
- };
311
398
 
312
- // Response documents 200 with the resource schema — parity with the
313
- // Python master, which emits ONLY 200 for a model write. The old code
314
- // stamped an unconditional 422 and a 201 the generator has no way to know
315
- // a given route returns; both were fiction on a path-inferred write. A
316
- // route that genuinely answers another code declares it via
317
- // meta.responses, which is honoured above and never clobbered here.
318
- if (route.meta?.responses === undefined) {
319
- operation.responses = {
320
- "200": { description: "Successful response", content: { "application/json": { schema: { $ref: sref } } } },
321
- };
322
- }
323
- } else if (route.meta?.example !== undefined) {
324
- // Non-model body with an explicit example.
325
- operation.requestBody = {
326
- content: { "application/json": { schema: inferSchema(route.meta.example), example: route.meta.example } },
399
+ // Response documents 200 with the resource schema — parity with the
400
+ // Python master, which emits ONLY 200 for a model write. The old code
401
+ // stamped an unconditional 422 and a 201 the generator has no way to know
402
+ // a given route returns; both were fiction on a path-inferred write. A
403
+ // route that genuinely answers another code declares it via
404
+ // meta.responses, which is honoured above and never clobbered here.
405
+ if (route.meta?.responses === undefined) {
406
+ operation.responses = {
407
+ "200": { description: "Successful response", content: { "application/json": { schema: { $ref: sref } } } },
327
408
  };
328
409
  }
410
+ } else if (route.meta?.example !== undefined) {
411
+ // Non-model body with an explicit example.
412
+ operation.requestBody = {
413
+ content: { "application/json": { schema: inferSchema(route.meta.example), example: route.meta.example } },
414
+ };
329
415
  }
416
+ }
417
+ }
330
418
 
331
- // Registered response schemas ($ref) — explicit and authoritative, keyed by status.
332
- const respSchemas = parseResponseSchemas(route.meta?.responseSchemas);
333
- if (respSchemas.length > 0) {
334
- const responses = operation.responses as Record<string, unknown>;
335
- for (const { status, name, isList } of respSchemas) {
336
- refSchemas.add(name);
337
- const sref = `#/components/schemas/${name}`;
338
- const schema = isList ? { type: "array", items: { $ref: sref } } : { $ref: sref };
339
- responses[status] = {
340
- description: status.startsWith("2") ? "Successful response" : "Response",
341
- content: { "application/json": { schema } },
342
- };
343
- }
419
+ /**
420
+ * Registered response schemas ($ref) — explicit and authoritative, keyed by
421
+ * status; each maps to the resource, or an array of it when marked as a list.
422
+ */
423
+ function operationResponseSchemas(
424
+ route: RouteDefinition,
425
+ operation: Record<string, unknown>,
426
+ refSchemas: Set<string>
427
+ ): void {
428
+ const respSchemas = parseResponseSchemas(route.meta?.responseSchemas);
429
+ if (respSchemas.length > 0) {
430
+ const responses = operation.responses as Record<string, unknown>;
431
+ for (const { status, name, isList } of respSchemas) {
432
+ refSchemas.add(name);
433
+ const sref = `#/components/schemas/${name}`;
434
+ const schema = isList ? { type: "array", items: { $ref: sref } } : { $ref: sref };
435
+ responses[status] = {
436
+ description: status.startsWith("2") ? "Successful response" : "Response",
437
+ content: { "application/json": { schema } },
438
+ };
344
439
  }
440
+ }
441
+ }
345
442
 
346
- // Security (v3.13.42) — explicit meta.security wins (empty list = explicitly
347
- // public); otherwise a secured route gets the default scheme. Scopes are kept
348
- // valid (only oauth2/openIdConnect carry them).
349
- const hasExplicitSecurity =
350
- route.meta?.security !== undefined || (route.meta?.scopes !== undefined && route.meta.scopes.length > 0);
351
- if (hasExplicitSecurity) {
352
- const normalized = normalizeSecurity(route.meta?.security, route.meta?.scopes);
353
- operation.security = normalized.length > 0 ? sanitizeSecurity(normalized, schemes) : [];
354
- if (normalized.length > 0) {
355
- const responses = operation.responses as Record<string, unknown>;
356
- if (!responses["401"]) responses["401"] = { description: "Unauthorized" };
357
- }
358
- } else if (routeRequiresAuth(route, method)) {
359
- const requirements = [{ [defaultScheme]: [] }];
360
- if (defaultScheme === "bearerAuth" && schemes.ssoSession) requirements.push({ ssoSession: [] });
361
- operation.security = sanitizeSecurity(requirements, schemes);
443
+ /**
444
+ * Per-route security (v3.13.42). Explicit meta.security wins (empty list =
445
+ * explicitly public); otherwise a secured route gets the default scheme (plus
446
+ * ssoSession when bearer + SSO is configured). A secured op also documents a
447
+ * 401. Scopes are kept valid (only oauth2/openIdConnect carry them).
448
+ */
449
+ function operationSecurity(
450
+ route: RouteDefinition,
451
+ method: string,
452
+ operation: Record<string, unknown>,
453
+ schemes: Record<string, Record<string, unknown>>,
454
+ defaultScheme: string
455
+ ): void {
456
+ const hasExplicitSecurity =
457
+ route.meta?.security !== undefined || (route.meta?.scopes !== undefined && route.meta.scopes.length > 0);
458
+ if (hasExplicitSecurity) {
459
+ const normalized = normalizeSecurity(route.meta?.security, route.meta?.scopes);
460
+ operation.security = normalized.length > 0 ? sanitizeSecurity(normalized, schemes) : [];
461
+ if (normalized.length > 0) {
362
462
  const responses = operation.responses as Record<string, unknown>;
363
463
  if (!responses["401"]) responses["401"] = { description: "Unauthorized" };
364
464
  }
365
-
366
- spec.paths[openApiPath][method] = operation;
465
+ } else if (routeRequiresAuth(route, method)) {
466
+ const requirements = [{ [defaultScheme]: [] }];
467
+ if (defaultScheme === "bearerAuth" && schemes.ssoSession) requirements.push({ ssoSession: [] });
468
+ operation.security = sanitizeSecurity(requirements, schemes);
469
+ const responses = operation.responses as Record<string, unknown>;
470
+ if (!responses["401"]) responses["401"] = { description: "Unauthorized" };
367
471
  }
472
+ }
368
473
 
369
- // Registered component schemas referenced via meta.requestSchema/responseSchemas.
474
+ /** Registered component schemas referenced via meta.requestSchema/responseSchemas. */
475
+ function buildRefSchemas(spec: OpenAPISpec, refSchemas: Set<string>): void {
370
476
  if (refSchemas.size > 0) {
371
477
  const schemas = spec.components!.schemas!;
372
478
  for (const name of refSchemas) {
@@ -375,12 +481,13 @@ export function generate(
375
481
  }
376
482
  }
377
483
  }
484
+ }
378
485
 
486
+ /** Top-level tags[] from the tags collected across operations (registration order). */
487
+ function buildTags(spec: OpenAPISpec, usedTags: string[]): void {
379
488
  if (usedTags.length > 0) {
380
489
  spec.tags = usedTags.map((name) => ({ name }));
381
490
  }
382
-
383
- return spec;
384
491
  }
385
492
 
386
493
  function routeRequiresAuth(route: RouteDefinition, method: string): boolean {
@@ -1,5 +1,5 @@
1
1
  export type { Tina4Request, Tina4Response, RouteHandler, RouteDefinition, RouteMeta, Tina4Config, Middleware, MiddlewareClass, MiddlewareSpec, UploadedFile, CookieOptions, WebSocketRouteHandler, WebSocketRouteDefinition, } from "./types.js";
2
- export { startServer, resolvePortAndHost, handle, start, stop, httpReason, resolveTemplate, resetTemplateCache, templateAutoRoutingEnabled, isBannerSuppressed } from "./server.js";
2
+ export { startServer, resolvePortAndHost, loopbackBindHosts, handle, start, stop, httpReason, resolveTemplate, resetTemplateCache, templateAutoRoutingEnabled, isBannerSuppressed } from "./server.js";
3
3
  export { background, stopAllBackgroundTasks, backgroundTaskCount } from "./background.js";
4
4
  export { Router, RouteGroup, RouteRef, WsRouteRef, defaultRouter, runRouteMiddlewares, resolveStringMiddleware, isTrailingSlashRedirectEnabled } from "./router.js";
5
5
  export { get, post, put, patch, del, any, websocket, del as delete } from "./router.js";
@@ -249,6 +249,21 @@ export declare function buildDispatchContext(router: Router, base?: string): Pro
249
249
  * skipped for one caller and not the other (feature 131, TC-DEC-01).
250
250
  */
251
251
  export declare function runDispatch(ctx: DispatchContext, rawReq: IncomingMessage, rawRes: ServerResponse): Promise<void>;
252
+ /**
253
+ * Sibling loopback addresses to ALSO listen on, so `localhost` reaches this
254
+ * server whether the OS resolves it to IPv4 (127.0.0.1) or IPv6 (::1).
255
+ *
256
+ * `localhost` resolves to `::1` (IPv6) FIRST on Windows, so a server bound only
257
+ * to `127.0.0.1` — or `0.0.0.0`, the IPv4 wildcard, which does NOT cover IPv6 —
258
+ * refuses the browser with ERR_CONNECTION_REFUSED even though it is serving,
259
+ * because nothing listens on `::1`. Binding the sibling family closes that gap.
260
+ *
261
+ * Returns only the families a direct bind of `host` does not already cover, as
262
+ * UNBRACKETED addresses (Node's net/http take a bare "::1"). A host that is
263
+ * neither loopback nor a wildcard yields an empty list — an explicit LAN
264
+ * address is bound exactly as asked. Mirrors PHP `Server::loopbackBindHosts`.
265
+ */
266
+ export declare function loopbackBindHosts(host: string): string[];
252
267
  export declare function startServer(config?: Tina4Config): Promise<{
253
268
  close: () => void;
254
269
  router: Router;
@@ -257,6 +257,17 @@ export declare class Frond {
257
257
  */
258
258
  private handleImportAs;
259
259
  private handleFromImport;
260
+ /**
261
+ * Collect the body tokens of a {% <openTag> %}...{% end<openTag> %} block,
262
+ * starting from the token after the opening tag (start + 1). Nested same-tag
263
+ * blocks are kept in the body and balanced by depth; the matching closing tag is
264
+ * consumed but NOT included. Returns [bodyTokens, indexAfterClosingTag].
265
+ *
266
+ * canNest guards the open-tag count: handleSetBlock passes it so the inline
267
+ * {% set x = 1 %} form (which has no {% endset %}) never opens a nested block —
268
+ * only the block form {% set x %} nests. Omitted, every openTag occurrence nests.
269
+ */
270
+ private collectBlockBody;
260
271
  private handleCache;
261
272
  /**
262
273
  * Handle {% live "name" poll N | sse | ws "path" [src "url"] %}...{% endlive %}.
@@ -1,3 +1,4 @@
1
+ import { ModelCollection } from "./modelCollection.js";
1
2
  import { QueryBuilder } from "./queryBuilder.js";
2
3
  import type { DatabaseAdapter, FieldDefinition, RelationshipDefinition } from "./types.js";
3
4
  /**
@@ -135,6 +136,20 @@ export declare class BaseModel {
135
136
  * Get the primary key database column name (applies fieldMapping).
136
137
  */
137
138
  protected static getPkColumn(): string;
139
+ /**
140
+ * Shared read tail for the collection-returning finders (where / all / select
141
+ * / find filter-form / withTrashed). Runs the SAME two calls `db.fetch()`
142
+ * makes — the page fetch AND the COUNT probe over the SAME base SQL — hydrates
143
+ * the rows into model instances, and returns a ModelCollection carrying the
144
+ * total (ADR-0064).
145
+ *
146
+ * The total is FREE: `probeTotal` is the exact `COUNT(*)` probe `db.fetch()`
147
+ * already runs; the ORM used to discard it. ZERO extra queries beyond that one
148
+ * probe. `sql` MUST NOT carry its own LIMIT/OFFSET — `adapterFetch` applies
149
+ * limit/offset to the page, and the probe wraps the un-limited SQL so it counts
150
+ * the WHOLE filtered set, not the page.
151
+ */
152
+ protected static _collect<T extends BaseModel>(this: typeof BaseModel & (new (data?: Record<string, unknown>) => T), sql: string, params: unknown[] | undefined, limit: number, offset: number, include?: string[]): Promise<ModelCollection<T>>;
138
153
  /**
139
154
  * Find a record by primary key.
140
155
  * @param id Primary key value.
@@ -174,7 +189,7 @@ export declare class BaseModel {
174
189
  * User.find() → all records
175
190
  */
176
191
  static find<T extends BaseModel>(this: new (data?: Record<string, unknown>) => T, pk: number | string, include?: string[]): Promise<T | null>;
177
- static find<T extends BaseModel>(this: new (data?: Record<string, unknown>) => T, filter?: Record<string, unknown>, limit?: number, offset?: number, orderBy?: string, include?: string[]): Promise<T[]>;
192
+ static find<T extends BaseModel>(this: new (data?: Record<string, unknown>) => T, filter?: Record<string, unknown>, limit?: number, offset?: number, orderBy?: string, include?: string[]): Promise<ModelCollection<T>>;
178
193
  /**
179
194
  * Load a record into this instance via selectOne.
180
195
  * Returns true if found and loaded, false otherwise.
@@ -218,7 +233,7 @@ export declare class BaseModel {
218
233
  * @param include Relationship names to eager-load.
219
234
  * @param orderBy ORDER BY clause (e.g. "name ASC").
220
235
  */
221
- static all<T extends BaseModel>(this: new (data?: Record<string, unknown>) => T, limit?: number, offset?: number, include?: string[], orderBy?: string): Promise<T[]>;
236
+ static all<T extends BaseModel>(this: new (data?: Record<string, unknown>) => T, limit?: number, offset?: number, include?: string[], orderBy?: string): Promise<ModelCollection<T>>;
222
237
  /**
223
238
  * Query records with a WHERE clause.
224
239
  * Matches Python/PHP/Ruby where() API.
@@ -230,7 +245,7 @@ export declare class BaseModel {
230
245
  * @param include Relationship names to eager-load
231
246
  * @param orderBy ORDER BY clause (e.g. "name ASC")
232
247
  */
233
- static where<T extends BaseModel>(this: new (data?: Record<string, unknown>) => T, conditions: string, params?: unknown[], limit?: number, offset?: number, include?: string[], orderBy?: string): Promise<T[]>;
248
+ static where<T extends BaseModel>(this: new (data?: Record<string, unknown>) => T, conditions: string, params?: unknown[], limit?: number, offset?: number, include?: string[], orderBy?: string): Promise<ModelCollection<T>>;
234
249
  /**
235
250
  * Save this instance (insert or update). Returns this on success (fluent
236
251
  * self), false on failure.
@@ -354,7 +369,7 @@ export declare class BaseModel {
354
369
  /**
355
370
  * Execute a raw SQL SELECT and return results as model instances.
356
371
  */
357
- static select<T extends BaseModel>(this: new (data?: Record<string, unknown>) => T, sql: string, params?: unknown[], limit?: number, offset?: number): Promise<T[]>;
372
+ static select<T extends BaseModel>(this: new (data?: Record<string, unknown>) => T, sql: string, params?: unknown[], limit?: number, offset?: number): Promise<ModelCollection<T>>;
358
373
  static selectOne<T extends BaseModel>(this: new (data?: Record<string, unknown>) => T, sql: string, params?: unknown[], include?: string[]): Promise<T | null>;
359
374
  /**
360
375
  * Permanently delete this instance, bypassing soft delete.
@@ -367,7 +382,7 @@ export declare class BaseModel {
367
382
  /**
368
383
  * Find records including soft-deleted ones.
369
384
  */
370
- static withTrashed<T extends BaseModel>(this: new (data?: Record<string, unknown>) => T, conditions?: string, params?: unknown[], limit?: number, offset?: number): Promise<T[]>;
385
+ static withTrashed<T extends BaseModel>(this: new (data?: Record<string, unknown>) => T, conditions?: string, params?: unknown[], limit?: number, offset?: number): Promise<ModelCollection<T>>;
371
386
  /**
372
387
  * Count records matching conditions (respects soft delete and table filter).
373
388
  */
@@ -2,6 +2,8 @@ export type { FieldType, FieldDefinition, ModelDefinition, DatabaseAdapter, Data
2
2
  export { REQUIRED_ADAPTER_CAPABILITIES, NOT_REQUIRED_ON_ADAPTER } from "./types.js";
3
3
  export { DatabaseResult } from "./databaseResult.js";
4
4
  export type { ColumnInfoResult } from "./databaseResult.js";
5
+ export { ModelCollection } from "./modelCollection.js";
6
+ export type { PaginateEnvelope } from "./modelCollection.js";
5
7
  export { Database, initDatabase, getAdapter, setAdapter, bindDatabase, createAdapterFromUrl, closeDatabase, parseDatabaseUrl, setNamedAdapter, getNamedAdapter, resolveDbPool, stripTrailingSemicolons, wrapWithCache, resetRequestCaches } from "./database.js";
6
8
  export { adapterFetch, adapterQuery, adapterFetchOne, adapterExecute, adapterInsert, adapterStartTransaction, adapterCommit, adapterRollback, adapterTableExists, adapterTables, adapterColumns, adapterCreateTable, extractLastInsertId, } from "./database.js";
7
9
  export type { DatabaseConfig } from "./database.js";