bazis 0.115.0 → 0.116.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.
@@ -88,6 +88,12 @@ export function normalizeResult(result: unknown, ctx: HttpContext, defaults: Res
88
88
  if (status === 204 || status === 304) {
89
89
  payload = null;
90
90
  }
91
+ // A known length survives HEAD: the server answers it with the headers of
92
+ // this response and no body, and Bun would otherwise send content-length: 0.
93
+ const length = typeof payload === "string" ? Buffer.byteLength(payload) : payload instanceof Uint8Array || payload instanceof ArrayBuffer ? payload.byteLength : undefined;
94
+ if (length !== undefined && !headers.has("content-length")) {
95
+ headers.set("content-length", String(length));
96
+ }
91
97
 
92
98
  return new Response(payload, { status, headers });
93
99
  }
@@ -232,7 +232,7 @@ export class CodexAppServerClient implements CodexClient {
232
232
  if (method === "account/updated") this.modelCache = undefined;
233
233
  }, () => { this.clearLogin(); this.modelCache = undefined; });
234
234
  const initialized = await client.request("initialize", {
235
- clientInfo: { name: "bazis", title: "Bazis", version: "0.115.0" }, capabilities: { experimentalApi: true },
235
+ clientInfo: { name: "bazis", title: "Bazis", version: "0.116.0" }, capabilities: { experimentalApi: true },
236
236
  }, 15_000, signal);
237
237
  if (resolve(text(initialized.codexHome, 4096)) !== this.paths.home) throw new CodexError("PROTOCOL_ERROR");
238
238
  client.notify("initialized");
@@ -1173,6 +1173,32 @@ function routeParamNames(controller: ts.ClassDeclaration, method: ts.MethodDecla
1173
1173
  return names;
1174
1174
  }
1175
1175
 
1176
+ /** Route parameters with a constraint, `:id(int)` -> id: int, from the controller and method templates. */
1177
+ function routeParamConstraints(controller: ts.ClassDeclaration, method: ts.MethodDeclaration): Map<string, string> {
1178
+ const constraints = new Map<string, string>();
1179
+ for (const decorators of [ts.getDecorators(controller) ?? [], ts.getDecorators(method) ?? []]) {
1180
+ for (const decorator of decorators) {
1181
+ const firstArg = decoratorCall(decorator)?.call?.arguments[0];
1182
+ if (!firstArg || !ts.isStringLiteralLike(firstArg)) continue;
1183
+ for (const segment of firstArg.text.split("/")) {
1184
+ const param = /^:([A-Za-z_][A-Za-z0-9_]*)\(([A-Za-z]+)\)/.exec(segment);
1185
+ if (param) constraints.set(param[1] as string, param[2] as string);
1186
+ }
1187
+ }
1188
+ }
1189
+ return constraints;
1190
+ }
1191
+
1192
+ /**
1193
+ * The router converts a constrained segment before binding, so the parameter
1194
+ * must be declared with the converted type; otherwise its TypeScript type lies.
1195
+ */
1196
+ function constraintMismatch(paramName: string, declared: string | undefined, constraint: string | undefined): string | undefined {
1197
+ const expected = constraint === "int" || constraint === "number" ? "number" : constraint === "bool" ? "boolean" : constraint === "uuid" || constraint === "alpha" ? "string" : undefined;
1198
+ if (expected === undefined || declared === undefined || declared === expected) return undefined;
1199
+ return `route parameter "${paramName}" is constrained to (${constraint}), so it arrives as a ${expected}; declare it as ${expected}`;
1200
+ }
1201
+
1176
1202
  /** Reuses the request hydration graph; no reflection metadata/runtime TS imports. */
1177
1203
  function collectGrpcBindings(source: ts.SourceFile): void {
1178
1204
  const visit = (node: ts.Node): void => {
@@ -1455,6 +1481,7 @@ function collectControllerBindings(node: ts.ClassDeclaration, controllerName: st
1455
1481
  for (const { name, routes, member } of controllerActions(node)) {
1456
1482
  collectOpenApiOperation(controllerName, member);
1457
1483
  const routeParams = routeParamNames(node, routes);
1484
+ const constraints = routeParamConstraints(node, routes);
1458
1485
  const specs: HttpBindingSpec[] = [];
1459
1486
  let bodyCount = 0;
1460
1487
  let failed: string | undefined;
@@ -1471,6 +1498,12 @@ function collectControllerBindings(node: ts.ClassDeclaration, controllerName: st
1471
1498
  // An explicit source: FromQuery<…>, FromHeader<…>, FromBody<…>, FromRoute<…>.
1472
1499
  const marker = typeNode ? sourceMarker(typeNode) : undefined;
1473
1500
  if (marker !== undefined) {
1501
+ const routeName = marker.kind === "FromRoute" ? marker.name ?? paramName : undefined;
1502
+ const mismatch = routeName === undefined ? undefined : constraintMismatch(routeName, marker.inner?.getText().trim(), constraints.get(routeName));
1503
+ if (mismatch !== undefined) {
1504
+ failed = mismatch;
1505
+ break;
1506
+ }
1474
1507
  const spec = markerBindingSpec(marker, paramName, optional, routeParams);
1475
1508
  if (typeof spec === "string") {
1476
1509
  failed = spec;
@@ -1494,6 +1527,11 @@ function collectControllerBindings(node: ts.ClassDeclaration, controllerName: st
1494
1527
  continue;
1495
1528
  }
1496
1529
  if (routeParams.has(paramName)) {
1530
+ const mismatch = constraintMismatch(paramName, keywordType, constraints.get(paramName));
1531
+ if (mismatch !== undefined) {
1532
+ failed = mismatch;
1533
+ break;
1534
+ }
1497
1535
  // Constraint conversion happens in the router; add type only for plain :params.
1498
1536
  const type = keywordType !== undefined ? PRIMITIVE_QUERY_TYPES[keywordType] : undefined;
1499
1537
  specs.push({ source: "route", name: paramName, type: type === "string" ? undefined : type, optional });
@@ -1,9 +1,9 @@
1
1
  import { DatabaseFacade } from "./DatabaseFacade";
2
2
  import type { DbContextOptions } from "./DbContextOptions";
3
- import { EntityNotMappedError } from "./errors";
3
+ import { EntityNotMappedError, OrmError } from "./errors";
4
4
  import { DbSet } from "./Query/DbSet";
5
5
  import type { DbContextRuntime } from "./runtime";
6
- import { SaveExecutor } from "./Saving/SaveExecutor";
6
+ import { hasPendingRollback, SaveExecutor } from "./Saving/SaveExecutor";
7
7
  import { ChangeTracker } from "./Tracking/ChangeTracker";
8
8
  import { EntityState } from "./Tracking/EntityState";
9
9
  import { monitorImmediateOperation, monitorWholeOperation, observedProvider, transactionScope } from "./Transactions/TransactionScopeCoordinator";
@@ -88,6 +88,20 @@ export abstract class DbContext {
88
88
  return this.changeTracker.stateOf(entity);
89
89
  }
90
90
 
91
+ /**
92
+ * Stops tracking `entity`: its changes are no longer saved and the next
93
+ * read returns a fresh object (`Entry(e).State = Detached` in EF Core). Use
94
+ * it to drop the changes a rolled-back scope left in the context; an entity
95
+ * saved in a scope that is still open cannot be detached, because that
96
+ * scope's rollback would restore it.
97
+ */
98
+ detach(entity: object): void {
99
+ if (hasPendingRollback(this.changeTracker, entity)) {
100
+ throw new OrmError(`${entity.constructor.name} was saved in a transaction scope that is still open; detach it after the scope ends.`);
101
+ }
102
+ this.changeTracker.detach(entity);
103
+ }
104
+
91
105
  /**
92
106
  * Applies all pending changes in one transaction and returns the number of
93
107
  * processed entities. DetectChanges runs before saving, and (if enabled)
@@ -708,7 +708,7 @@ export class OwnedStoreCatalogReader {
708
708
  const output: OwnedCatalogColumnV1[] = [], relationKinds = new Map(relations.map(relation => [relation.oid, relation.kind]));
709
709
  for (let offset = 0; offset < relationOids.length; offset += 1024) {
710
710
  const batch = relationOids.slice(offset, offset + 1024), parameter = this.encodePgOidArrayParameter(batch), issuedLimit = budget.remaining + 1;
711
- const result = await this.source.query(`SELECT a.attrelid::pg_catalog.text AS relation_oid,a.attnum::pg_catalog.text AS attnum,a.attname AS column_name,a.attisdropped AS dropped,a.attislocal AS local,a.attinhcount::pg_catalog.text AS inheritance_count,pg_catalog.format_type(a.atttypid,a.atttypmod) AS physical_type,a.atttypid::pg_catalog.text AS type_oid,a.attnotnull AS not_null,d.oid::pg_catalog.text AS default_object_oid,pg_catalog.pg_get_expr(d.adbin,d.adrelid) AS default_expression,a.attidentity AS identity_code,a.attgenerated AS generated_code,a.attcollation::pg_catalog.text AS collation_oid,t.typcollation::pg_catalog.text AS type_default_collation_oid,a.attstorage AS storage_code,t.typstorage AS type_default_storage_code,a.attcompression AS compression_code,a.atthasdef AS has_default FROM pg_catalog.pg_attribute AS a LEFT JOIN pg_catalog.pg_type AS t ON t.oid=a.atttypid LEFT JOIN pg_catalog.pg_attrdef AS d ON d.adrelid=a.attrelid AND d.adnum=a.attnum WHERE a.attrelid=ANY($1::pg_catalog.oid[]) AND a.attnum>0 ORDER BY a.attrelid,a.attnum LIMIT ${issuedLimit}`, [parameter], operation);
711
+ const result = await this.source.query(`SELECT a.attrelid::pg_catalog.text AS relation_oid,a.attnum::pg_catalog.text AS attnum,a.attname AS column_name,a.attisdropped AS dropped,a.attislocal AS local,a.attinhcount::pg_catalog.text AS inheritance_count,pg_catalog.format_type(a.atttypid,a.atttypmod) AS physical_type,a.atttypid::pg_catalog.text AS type_oid,a.attnotnull AS not_null,d.oid::pg_catalog.text AS default_object_oid,pg_catalog.pg_get_expr(d.adbin,d.adrelid) AS default_expression,a.attidentity AS identity_code,a.attgenerated AS generated_code,a.attcollation::pg_catalog.text AS collation_oid,t.typcollation::pg_catalog.text AS type_default_collation_oid,a.attstorage AS storage_code,t.typstorage AS type_default_storage_code,a.attcompression AS compression_code,a.atthasdef AS has_default FROM pg_catalog.pg_attribute AS a LEFT JOIN pg_catalog.pg_type AS t ON t.oid=a.atttypid LEFT JOIN pg_catalog.pg_attrdef AS d ON d.adrelid=a.attrelid AND d.adnum=a.attnum WHERE a.attrelid=ANY($1::pg_catalog.oid[]) AND a.attnum>0 AND NOT a.attisdropped ORDER BY a.attrelid,a.attnum LIMIT ${issuedLimit}`, [parameter], operation);
712
712
  const rows = selectOwnedStoreRows(result, [
713
713
  "relation_oid", "attnum", "column_name", "dropped", "local", "inheritance_count", "physical_type", "type_oid", "not_null", "default_object_oid", "default_expression", "identity_code", "generated_code", "collation_oid", "type_default_collation_oid", "storage_code", "type_default_storage_code", "compression_code", "has_default"
714
714
  ], issuedLimit);
@@ -36,6 +36,11 @@ interface RollbackToken {
36
36
 
37
37
  /** Per-tracker, per-entity save baselines. It is deliberately transient and never part of TrackedEntry. */
38
38
  const rollbackTokens = new WeakMap<ChangeTracker, WeakMap<object, RollbackToken>>();
39
+
40
+ /** @internal The entity was saved in a transaction scope that has not ended yet. */
41
+ export function hasPendingRollback(tracker: ChangeTracker, entity: object): boolean {
42
+ return rollbackTokens.get(tracker)?.get(entity) !== undefined;
43
+ }
39
44
  const uncertainTrackers = new WeakSet<ChangeTracker>();
40
45
 
41
46
  export interface SaveOptions {
@@ -1128,13 +1128,17 @@ function verifyOwnedStoreCatalogModelsV1(snapshot: OwnedStoreCatalogSnapshotV1,
1128
1128
  readonly default: CanonicalDefault;
1129
1129
  readonly generation: "none" | "identityByDefault" | "uuidDefault";
1130
1130
  }>();
1131
+ // A column added and dropped outside bazis stays in pg_attribute as a
1132
+ // dropped attribute: it holds no reachable data and cannot be removed
1133
+ // without rewriting the table. The reader skips such attributes, so live
1134
+ // columns may have gaps in attnum; their order is what counts.
1131
1135
  const tableColumns = columns.get(relation.oid) ?? [];
1132
1136
  if (snapshot.columns.some((column) => !owned.has(column.relationOid) && !declaredToastRelationOids.has(column.relationOid)) || tableColumns.length !== expectedTable.columns.length)
1133
1137
  bad();
1134
1138
  const orderedColumns = [...tableColumns].sort((a, b) => Number(a.attnum) - Number(b.attnum));
1135
1139
  for (let index = 0; index < orderedColumns.length; index++) {
1136
1140
  const column = orderedColumns[index]!, modelColumn = expectedTable.columns[index]!;
1137
- if (column.name !== modelColumn.column || column.attnum !== String(index + 1) || column.dropped || !column.local || column.inheritanceCount !== "0" || column.typeOid === "0" || BigInt(column.typeOid) >= 12000n || !["integer", "real", "text", "boolean", "datetime", "json", "uuid"].includes(column.physicalType) || column.collationOid !== column.typeDefaultCollationOid || column.storageCode !== column.typeDefaultStorageCode || column.compressionCode !== "" || column.generation === "other")
1141
+ if (column.name !== modelColumn.column || column.dropped || !column.local || column.inheritanceCount !== "0" || column.typeOid === "0" || BigInt(column.typeOid) >= 12000n || !["integer", "real", "text", "boolean", "datetime", "json", "uuid"].includes(column.physicalType) || column.collationOid !== column.typeDefaultCollationOid || column.storageCode !== column.typeDefaultStorageCode || column.compressionCode !== "" || column.generation === "other")
1138
1142
  bad();
1139
1143
  const expectedGeneration = modelColumn.generation;
1140
1144
  if (column.generation !== expectedGeneration || (column.generation === "none" && (column.identityCode !== "" || column.generatedCode !== "")) || (column.generation === "identityByDefault" && (column.identityCode !== "d" || column.generatedCode !== "")) || (column.generation === "uuidDefault" && (column.identityCode !== "" || column.generatedCode !== "")))
@@ -243,6 +243,14 @@ export class ChangeTracker {
243
243
  this.entries.set(entry.entity, entry);
244
244
  }
245
245
 
246
+ /** Stops tracking one entity: it becomes Detached and leaves the identity map. */
247
+ detach(entity: object): void {
248
+ const entry = this.entries.get(entity);
249
+ if (!entry) return;
250
+ this.unindexByKey(entry);
251
+ this.entries.delete(entity);
252
+ }
253
+
246
254
  clear(): void {
247
255
  this.entries.clear();
248
256
  this.identity.clear();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bazis",
3
- "version": "0.115.0",
3
+ "version": "0.116.0",
4
4
  "description": "Modular backend framework for Bun in TypeScript: DI wired by codegen, HTTP, a PostgreSQL ORM, JWT, WebSocket, agents and a CLI.",
5
5
  "license": "MIT",
6
6
  "author": "Alexander Alenin",