bazis 0.98.16 → 0.98.17

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.
@@ -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.98.16" }, capabilities: { experimentalApi: true },
235
+ clientInfo: { name: "bazis", title: "Bazis", version: "0.98.17" }, 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");
@@ -7,7 +7,8 @@ import type { DatabaseProvider, DbExecutor } from "../Providers/types";
7
7
  import { isUnknownTransactionOutcome, registerTransactionUncertainty, TransactionOutcomeUnknownError } from "../Providers/transactionOutcome";
8
8
  import { registerPostCommitFinalizer } from "../Providers/transactionCallbacks";
9
9
  import { EntityState } from "../Tracking/EntityState";
10
- import { assertUniqueTrackedKeys, holdDeletedIdentity, type ChangeTracker, type TrackedEntry } from "../Tracking/ChangeTracker";
10
+ import { assertUniqueTrackedKeys, holdDeletedIdentity, trackedEntriesOf, type ChangeTracker, type TrackedEntry } from "../Tracking/ChangeTracker";
11
+ import { NavigationFixup } from "./navigationFixup";
11
12
  import { CommandBuilder } from "./CommandBuilder";
12
13
  import { applyConventions } from "./applyConventions";
13
14
  import { ExecutionStrategy, type ExecutionStrategyOptions } from "./ExecutionStrategy";
@@ -78,6 +79,10 @@ export class SaveExecutor {
78
79
 
79
80
  private async saveCore(): Promise<number> {
80
81
  if (uncertainTrackers.has(this.tracker)) throw new TransactionOutcomeUnknownError();
82
+ // Foreign keys from navigations first, so a changed navigation on an
83
+ // Unchanged entity is detected as a Modified foreign key.
84
+ const fixup = new NavigationFixup(trackedEntriesOf(this.tracker));
85
+ fixup.applyAll();
81
86
  this.tracker.detectChanges();
82
87
  const pending = this.tracker.entriesToProcess();
83
88
  if (pending.length === 0) {
@@ -127,9 +132,14 @@ export class SaveExecutor {
127
132
  group.push(ordered[i]!);
128
133
  i += 1;
129
134
  }
135
+ // Parents are inserted first: their generated keys are known now.
136
+ for (const added of group) fixup.apply(added);
130
137
  await this.insertBatch(tx, entry.model, group);
131
138
  continue;
132
139
  }
140
+ if (entry.state === EntityState.Modified) {
141
+ for (const name of fixup.apply(entry)) entry.modifiedProperties.add(name);
142
+ }
133
143
  await this.execute(tx, entry);
134
144
  i += 1;
135
145
  }
@@ -0,0 +1,80 @@
1
+ import { EntityState } from "../Tracking/EntityState";
2
+ import type { TrackedEntry } from "../Tracking/ChangeTracker";
3
+
4
+ type Values = Record<string, unknown>;
5
+
6
+ /**
7
+ * Foreign keys from navigations, as EF Core's relationship fix-up does:
8
+ * `article.author = bob` (a `@ManyToOne`) or `bob.articles.push(article)`
9
+ * (a `@OneToMany`) set `article.authorId` from `bob`'s key. Only navigations to
10
+ * entities tracked by the same context are followed; the navigation wins over
11
+ * a different value in the foreign key property.
12
+ */
13
+ export class NavigationFixup {
14
+ /** child entity -> the parents whose collection holds it. */
15
+ private readonly parents = new Map<object, { readonly parent: TrackedEntry; readonly foreignKey: readonly string[] }[]>();
16
+
17
+ constructor(private readonly entries: ReadonlyMap<object, TrackedEntry>) {
18
+ for (const entry of entries.values()) {
19
+ for (const relation of entry.model.relations) {
20
+ if (relation.kind !== "collection") continue;
21
+ const children = (entry.entity as Values)[relation.navigationName];
22
+ if (!Array.isArray(children)) continue;
23
+ for (const child of children) {
24
+ if (child === null || typeof child !== "object" || !entries.has(child)) continue;
25
+ const list = this.parents.get(child) ?? [];
26
+ list.push({ parent: entry, foreignKey: toArray(relation.foreignKey) });
27
+ this.parents.set(child, list);
28
+ }
29
+ }
30
+ }
31
+ }
32
+
33
+ /** Applies the fix-up to every tracked entry (before change detection). */
34
+ applyAll(): void {
35
+ for (const entry of this.entries.values()) {
36
+ if (entry.state !== EntityState.Deleted) this.apply(entry);
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Applies the fix-up to one entry and returns the foreign key properties it
42
+ * changed. Called again right before the entry's SQL, when the keys the
43
+ * database generated for its parents are known.
44
+ */
45
+ apply(entry: TrackedEntry): readonly string[] {
46
+ const changed: string[] = [];
47
+ const entity = entry.entity as Values;
48
+ for (const relation of entry.model.relations) {
49
+ if (relation.kind !== "reference") continue;
50
+ const target = entity[relation.navigationName];
51
+ if (target === null || typeof target !== "object") continue;
52
+ const targetEntry = this.entries.get(target);
53
+ if (targetEntry === undefined) continue;
54
+ this.copyKey(entity, toArray(relation.foreignKey), targetEntry, changed);
55
+ }
56
+ for (const { parent, foreignKey } of this.parents.get(entry.entity) ?? []) {
57
+ if (parent.state === EntityState.Deleted) continue;
58
+ this.copyKey(entity, foreignKey, parent, changed);
59
+ }
60
+ return changed;
61
+ }
62
+
63
+ private copyKey(entity: Values, foreignKey: readonly string[], target: TrackedEntry, changed: string[]): void {
64
+ const key = target.model.key;
65
+ if (key.length !== foreignKey.length) return;
66
+ const source = target.entity as Values;
67
+ key.forEach((property, index) => {
68
+ const name = foreignKey[index]!;
69
+ const value = source[property.propertyName];
70
+ if (entity[name] !== value) {
71
+ entity[name] = value;
72
+ changed.push(name);
73
+ }
74
+ });
75
+ }
76
+ }
77
+
78
+ function toArray(value: string | readonly string[]): readonly string[] {
79
+ return typeof value === "string" ? [value] : value;
80
+ }
@@ -342,6 +342,11 @@ export class ChangeTracker {
342
342
  }
343
343
  }
344
344
 
345
+ /** Non-barrel access to every tracked entry, for the save-time navigation fix-up. */
346
+ export function trackedEntriesOf(tracker: ChangeTracker): ReadonlyMap<object, TrackedEntry> {
347
+ return trackerEntries.get(tracker) ?? new Map();
348
+ }
349
+
345
350
  /** Non-barrel helper used only by immediate DML admission. */
346
351
  export function hasTrackedEntriesForModel(tracker: ChangeTracker, model: EntityModel): boolean {
347
352
  return [...(trackerEntries.get(tracker)?.values() ?? [])].some((entry) => entry.model === model);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bazis",
3
- "version": "0.98.16",
3
+ "version": "0.98.17",
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",