sonamu 0.10.4 → 0.10.6

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 (82) hide show
  1. package/dist/bin/cli.js +206 -187
  2. package/dist/cone/cone-generator.js +3 -9
  3. package/dist/database/knex.d.ts.map +1 -1
  4. package/dist/database/knex.js +2 -2
  5. package/dist/database/puri.d.ts +17 -0
  6. package/dist/database/puri.d.ts.map +1 -1
  7. package/dist/database/puri.js +102 -3
  8. package/dist/migration/code-generation.js +6 -6
  9. package/dist/migration/migrator.d.ts.map +1 -1
  10. package/dist/migration/migrator.js +51 -6
  11. package/dist/ui-web/assets/{index-CDd6xT-F.js → index-D0MHYbxl.js} +2 -2
  12. package/dist/ui-web/assets/index-Dx_JX4aQ.css +1 -0
  13. package/dist/ui-web/index.html +2 -2
  14. package/package.json +2 -2
  15. package/src/bin/cli.ts +283 -282
  16. package/src/cone/cone-generator.ts +2 -18
  17. package/src/database/__tests__/puri.test.ts +183 -0
  18. package/src/database/knex.ts +4 -1
  19. package/src/database/puri.ts +229 -2
  20. package/src/database/puri.types.test-d.ts +56 -0
  21. package/src/migration/code-generation.ts +5 -5
  22. package/src/migration/migrator.ts +81 -7
  23. package/src/skills/AGENTS.md +76 -50
  24. package/src/skills/sonamu/SKILL.md +46 -227
  25. package/src/skills/{sonamu/ai-agents.md → sonamu-ai-agents/SKILL.md} +3 -3
  26. package/src/skills/{sonamu/api.md → sonamu-api/SKILL.md} +3 -2
  27. package/src/skills/{sonamu/auth.md → sonamu-auth/SKILL.md} +9 -4
  28. package/src/skills/{sonamu/auth-plugins.md → sonamu-auth/references/plugins.md} +2 -7
  29. package/src/skills/sonamu-auth/references/user-id-migration-followups.md +192 -0
  30. package/src/skills/{sonamu/auth-migration.md → sonamu-auth/references/user-id-migration.md} +1 -195
  31. package/src/skills/sonamu-config/SKILL.md +203 -0
  32. package/src/skills/{sonamu → sonamu-config/references}/database.md +3 -8
  33. package/src/skills/sonamu-config/references/environments.md +178 -0
  34. package/src/skills/sonamu-config/references/server-options.md +400 -0
  35. package/src/skills/sonamu-entity/SKILL.md +180 -0
  36. package/src/skills/{sonamu/entity-validation-checklist.md → sonamu-entity/references/creation-workflow.md} +117 -8
  37. package/src/skills/sonamu-entity/references/design-guides.md +243 -0
  38. package/src/skills/sonamu-entity/references/field-types.md +170 -0
  39. package/src/skills/sonamu-entity/references/relations-detail.md +245 -0
  40. package/src/skills/{sonamu/entity-relations.md → sonamu-entity/references/relations.md} +1 -258
  41. package/src/skills/{sonamu → sonamu-entity/references}/subset.md +1 -11
  42. package/src/skills/sonamu-fixture/SKILL.md +180 -0
  43. package/src/skills/{sonamu/fixture-cli.md → sonamu-fixture/references/cli-usage.md} +5 -176
  44. package/src/skills/{sonamu → sonamu-fixture/references}/cone.md +4 -14
  45. package/src/skills/sonamu-frontend/SKILL.md +142 -0
  46. package/src/skills/sonamu-frontend/references/components.md +323 -0
  47. package/src/skills/sonamu-frontend/references/examples.md +64 -0
  48. package/src/skills/sonamu-frontend/references/hooks.md +273 -0
  49. package/src/skills/sonamu-frontend/references/runtime.md +165 -0
  50. package/src/skills/{sonamu → sonamu-frontend/references}/scaffolding.md +3 -8
  51. package/src/skills/{sonamu/i18n.md → sonamu-i18n/SKILL.md} +2 -2
  52. package/src/skills/{sonamu/migration.md → sonamu-migration/SKILL.md} +1 -1
  53. package/src/skills/{sonamu/naite.md → sonamu-naite/SKILL.md} +5 -5
  54. package/src/skills/sonamu-query/SKILL.md +48 -0
  55. package/src/skills/sonamu-query/references/model-patterns.md +390 -0
  56. package/src/skills/{sonamu → sonamu-query/references}/model.md +1 -401
  57. package/src/skills/{sonamu → sonamu-query/references}/puri.md +31 -243
  58. package/src/skills/sonamu-query/references/search.md +238 -0
  59. package/src/skills/{sonamu → sonamu-query/references}/upsert.md +1 -6
  60. package/src/skills/{sonamu/tasks.md → sonamu-tasks/SKILL.md} +1 -1
  61. package/src/skills/sonamu-testing/SKILL.md +251 -0
  62. package/src/skills/{sonamu/testing-devrunner.md → sonamu-testing/references/devrunner.md} +4 -9
  63. package/src/skills/sonamu-testing/references/helpers.md +185 -0
  64. package/src/skills/sonamu-testing/references/patterns.md +263 -0
  65. package/src/skills/sonamu-testing/references/pitfalls.md +588 -0
  66. package/src/skills/sonamu-testing/references/quick-start.md +285 -0
  67. package/src/skills/sonamu-testing/references/type-safety.md +172 -0
  68. package/src/skills/sonamu-testing/references/writing-plan.md +375 -0
  69. package/src/skills/{sonamu/vector.md → sonamu-vector/SKILL.md} +1 -1
  70. package/dist/ui-web/assets/index-Dx4ap5i4.css +0 -1
  71. package/src/skills/commands/sonamu-skills.md +0 -20
  72. package/src/skills/project/README.md +0 -19
  73. package/src/skills/project/architecture.md +0 -373
  74. package/src/skills/sonamu/cdd.md +0 -129
  75. package/src/skills/sonamu/config.md +0 -772
  76. package/src/skills/sonamu/create-sonamu.md +0 -208
  77. package/src/skills/sonamu/entity-basic.md +0 -678
  78. package/src/skills/sonamu/framework-change.md +0 -96
  79. package/src/skills/sonamu/frontend.md +0 -915
  80. package/src/skills/sonamu/project-init.md +0 -477
  81. package/src/skills/sonamu/skill-contribution.md +0 -247
  82. package/src/skills/sonamu/testing.md +0 -2163
@@ -85,10 +85,9 @@ function getApiKey(): string {
85
85
  }
86
86
 
87
87
  /**
88
- * 도메인별 {domain}.contract.md와 architecture.md를 읽어 컨텍스트로 반환합니다.
88
+ * 도메인별 {domain}.contract.md를 읽어 컨텍스트로 반환합니다.
89
89
  *
90
- * - contract/{domain}/{domain}.contract.md: 도메인 규칙과 결정 근거 (주 참조 대상)
91
- * - .claude/skills/project/architecture.md: 엔티티 설계 구조 (보조 참조)
90
+ * - contract/{domain}/{domain}.contract.md: 도메인 규칙과 결정 근거
92
91
  *
93
92
  * cone 생성 시 LLM에게 전달하여 도메인 맥락에 맞는 메타데이터를 생성하도록 합니다.
94
93
  */
@@ -122,21 +121,6 @@ function readProjectSkills(): string {
122
121
  }
123
122
  }
124
123
 
125
- // .claude/skills/project/architecture.md 보조 참조
126
- const architecturePath = path.join(
127
- projectRoot,
128
- ".claude",
129
- "skills",
130
- "project",
131
- "architecture.md",
132
- );
133
- if (fs.existsSync(architecturePath)) {
134
- const content = fs.readFileSync(architecturePath, "utf-8").trim();
135
- if (content) {
136
- contents.push(`--- architecture.md ---\n${content}`);
137
- }
138
- }
139
-
140
124
  return contents.join("\n\n");
141
125
  } catch {
142
126
  // Sonamu 미초기화 또는 파일 접근 오류 시 빈 문자열 반환
@@ -7,6 +7,7 @@ type TestSchema = {
7
7
  users: {
8
8
  id: number;
9
9
  name: string;
10
+ department_id: number | null;
10
11
  payload: {
11
12
  actor: {
12
13
  id: string;
@@ -18,6 +19,14 @@ type TestSchema = {
18
19
  state: string;
19
20
  readonly __json__: readonly ["payload", "tags", "state"];
20
21
  };
22
+ departments: {
23
+ id: number;
24
+ name: string;
25
+ };
26
+ companies: {
27
+ id: number;
28
+ name: string;
29
+ };
21
30
  };
22
31
 
23
32
  const db = knex({ client: "pg" });
@@ -99,3 +108,177 @@ describe("Puri JSONB containment", () => {
99
108
  expect(query.rawQuery().toSQL().sql).toBe('select * from "users"');
100
109
  });
101
110
  });
111
+
112
+ describe("Puri ensureJoin", () => {
113
+ it("기존의 동일한 JOIN을 재사용한다", () => {
114
+ const query = usersQuery()
115
+ .join({ department: "departments" }, "users.department_id", "department.id")
116
+ .ensureJoin({ department: "departments" }, "users.department_id", "department.id");
117
+
118
+ expect(query.toQuery().match(/join "departments" as "department"/g)).toHaveLength(1);
119
+ });
120
+
121
+ it("등록되지 않은 JOIN을 추가한다", () => {
122
+ const query = usersQuery().ensureJoin(
123
+ { department: "departments" },
124
+ "users.department_id",
125
+ "department.id",
126
+ );
127
+
128
+ expect(query.toQuery()).toContain(
129
+ 'join "departments" as "department" on "users"."department_id" = "department"."id"',
130
+ );
131
+ });
132
+
133
+ it("같은 alias의 JOIN 조건이 다르면 실행 전에 오류를 던진다", () => {
134
+ const query = usersQuery().join(
135
+ { department: "departments" },
136
+ "users.department_id",
137
+ "department.id",
138
+ );
139
+
140
+ expect(() =>
141
+ query.ensureJoin({ department: "departments" }, "users.id", "department.id"),
142
+ ).toThrowError(
143
+ [
144
+ 'Join alias "department" is already registered with a different definition.',
145
+ "Existing: JOIN departments AS department ON users.department_id = department.id",
146
+ "Requested: JOIN departments AS department ON users.id = department.id",
147
+ ].join("\n"),
148
+ );
149
+ });
150
+
151
+ it.each([
152
+ {
153
+ name: "테이블",
154
+ create: () =>
155
+ usersQuery()
156
+ .join({ department: "departments" }, "users.department_id", "department.id")
157
+ .ensureJoin({ department: "companies" }, "users.department_id", "department.id"),
158
+ requested: "Requested: JOIN companies AS department ON users.department_id = department.id",
159
+ },
160
+ {
161
+ name: "JOIN 타입",
162
+ create: () =>
163
+ usersQuery()
164
+ .join({ department: "departments" }, "users.department_id", "department.id")
165
+ .ensureLeftJoin({ department: "departments" }, "users.department_id", "department.id"),
166
+ requested:
167
+ "Requested: LEFT JOIN departments AS department ON users.department_id = department.id",
168
+ },
169
+ {
170
+ name: "왼쪽 조건",
171
+ create: () =>
172
+ usersQuery()
173
+ .join({ department: "departments" }, "users.department_id", "department.id")
174
+ .ensureJoin({ department: "departments" }, "users.id", "department.id"),
175
+ requested: "Requested: JOIN departments AS department ON users.id = department.id",
176
+ },
177
+ {
178
+ name: "오른쪽 조건",
179
+ create: () =>
180
+ usersQuery()
181
+ .join({ department: "departments" }, "users.department_id", "department.id")
182
+ .ensureJoin({ department: "departments" }, "users.department_id", "department.name"),
183
+ requested:
184
+ "Requested: JOIN departments AS department ON users.department_id = department.name",
185
+ },
186
+ ])("같은 alias의 $name 정의가 다르면 실행 전에 오류를 던진다", ({ create, requested }) => {
187
+ expect(create).toThrowError(
188
+ 'Join alias "department" is already registered with a different definition.',
189
+ );
190
+ expect(create).toThrowError(requested);
191
+ });
192
+
193
+ it("일반 JOIN은 같은 alias를 중복 등록할 수 없다", () => {
194
+ const query = usersQuery().join(
195
+ { department: "departments" },
196
+ "users.department_id",
197
+ "department.id",
198
+ );
199
+
200
+ expect(() =>
201
+ query.join({ department: "departments" }, "users.department_id", "department.id"),
202
+ ).toThrowError(/Join alias "department" is already registered/);
203
+ });
204
+
205
+ it("같은 테이블을 서로 다른 alias로 JOIN할 수 있다", () => {
206
+ const query = usersQuery()
207
+ .ensureJoin(
208
+ { primary_department: "departments" },
209
+ "users.department_id",
210
+ "primary_department.id",
211
+ )
212
+ .ensureJoin({ fallback_department: "departments" }, "users.id", "fallback_department.id");
213
+
214
+ expect(query.toQuery()).toContain('join "departments" as "primary_department"');
215
+ expect(query.toQuery()).toContain('join "departments" as "fallback_department"');
216
+ });
217
+
218
+ it("기존의 동일한 LEFT JOIN을 재사용한다", () => {
219
+ const query = usersQuery()
220
+ .leftJoin({ department: "departments" }, "users.department_id", "department.id")
221
+ .ensureLeftJoin({ department: "departments" }, "users.department_id", "department.id");
222
+
223
+ expect(query.toQuery().match(/left join "departments" as "department"/g)).toHaveLength(1);
224
+ });
225
+
226
+ it("callback JOIN은 동일성을 추론하지 않는다", () => {
227
+ const query = usersQuery().join({ department: "departments" }, (join) => {
228
+ join.on("users.department_id", "department.id");
229
+ });
230
+
231
+ expect(() =>
232
+ query.ensureJoin({ department: "departments" }, "users.department_id", "department.id"),
233
+ ).toThrowError(/Existing: JOIN departments AS department with opaque condition/);
234
+ });
235
+
236
+ it("clone은 JOIN registry를 독립적으로 복제한다", () => {
237
+ const original = usersQuery().ensureJoin(
238
+ { department: "departments" },
239
+ "users.department_id",
240
+ "department.id",
241
+ );
242
+ const cloned = original
243
+ .clone()
244
+ .ensureJoin({ department: "departments" }, "users.department_id", "department.id")
245
+ .ensureJoin({ fallback: "departments" }, "users.id", "fallback.id");
246
+
247
+ expect(original.toQuery()).not.toContain('as "fallback"');
248
+ expect(cloned.toQuery()).toContain('as "fallback"');
249
+ expect(cloned.toQuery().match(/join "departments" as "department"/g)).toHaveLength(1);
250
+ });
251
+
252
+ it("JOIN을 clear한 뒤 같은 alias를 다시 등록할 수 있다", () => {
253
+ const query = usersQuery()
254
+ .ensureJoin({ department: "departments" }, "users.department_id", "department.id")
255
+ .clear("join")
256
+ .ensureJoin({ department: "departments" }, "users.department_id", "department.id");
257
+
258
+ expect(query.toQuery().match(/join "departments" as "department"/g)).toHaveLength(1);
259
+ });
260
+
261
+ it("clearJoin으로 삭제한 alias를 다시 등록할 수 있다", () => {
262
+ const query = usersQuery()
263
+ .ensureJoin({ department: "departments" }, "users.department_id", "department.id")
264
+ .clearJoin("department")
265
+ .ensureJoin({ department: "departments" }, "users.department_id", "department.id");
266
+
267
+ expect(query.toQuery().match(/join "departments" as "department"/g)).toHaveLength(1);
268
+ });
269
+
270
+ it("clearJoin은 subquery JOIN의 registry도 함께 제거한다", () => {
271
+ const departmentIds = new Puri<
272
+ TestSchema,
273
+ { departments: TestSchema["departments"] },
274
+ TestSchema["departments"]
275
+ >(db, "departments").select({ id: "departments.id" });
276
+ const query = usersQuery()
277
+ .join({ department: departmentIds }, "users.department_id", "department.id")
278
+ .clearJoin("department")
279
+ .ensureJoin({ department: "departments" }, "users.department_id", "department.id");
280
+
281
+ expect(query.toQuery()).not.toContain('select "id" from "departments"');
282
+ expect(query.toQuery().match(/join "departments" as "department"/g)).toHaveLength(1);
283
+ });
284
+ });
@@ -12,14 +12,17 @@ export function createKnexInstance(config: Knex.Config): Knex {
12
12
  }
13
13
 
14
14
  config.pool = {
15
+ // 아래는 기본값이며, 호출측이 config.pool로 덮어쓸 수 있다.
16
+ // (예: 마이그레이션 상태 조회는 fail-fast를 위해 짧은 타임아웃 + propagateCreateError를 지정)
15
17
  maxConnectionLifetimeMillis: 1800000,
16
18
  maxConnectionLifetimeJitterMillis: 300000,
17
- ...config.pool,
18
19
  propagateCreateError: false,
19
20
  idleTimeoutMillis: 10000,
20
21
  reapIntervalMillis: 1000,
21
22
  acquireTimeoutMillis: 30000,
22
23
  createTimeoutMillis: 30000,
24
+ ...config.pool,
25
+ // validate/afterCreate는 항상 프레임워크 기본값을 사용한다.
23
26
  validate: (connection: unknown) => {
24
27
  if (typeof connection !== "object" || connection === null) return false;
25
28
  const conn = connection as Record<string, unknown>;
@@ -61,6 +61,24 @@ type PuriOrderByRuntimeItem = {
61
61
  };
62
62
  type PuriOrderByRuntimeEntry = string | PuriOrderByExpression | PuriOrderByRuntimeItem;
63
63
 
64
+ type FromRegistration = {
65
+ kind: "from";
66
+ alias: string;
67
+ table: string | null;
68
+ };
69
+
70
+ type JoinRegistration = {
71
+ kind: "join";
72
+ alias: string;
73
+ table: string | null;
74
+ joinType: "join" | "leftJoin";
75
+ left: string | null;
76
+ right: string | null;
77
+ reusable: boolean;
78
+ };
79
+
80
+ type CorrelationRegistration = FromRegistration | JoinRegistration;
81
+
64
82
  function normalizeFuzzyOperator(operator?: string): FuzzyOperator {
65
83
  const normalized = operator?.trim() ?? "<%";
66
84
  const fuzzyOperator = FUZZY_OPERATORS.find((candidate) => candidate === normalized);
@@ -123,6 +141,7 @@ function isSqlExpression(value: PuriOrderByRuntimeEntry): value is PuriOrderByEx
123
141
  export class Puri<TSchema, TTables extends Record<string, any>, TResult> {
124
142
  private knexQuery: Knex.QueryBuilder;
125
143
  private tableSpec: TableSpec | null = null;
144
+ private correlationRegistry = new Map<string, CorrelationRegistration>();
126
145
 
127
146
  // 생성자 시그니처들
128
147
  constructor(knex: Knex, tableName: string);
@@ -135,6 +154,11 @@ export class Puri<TSchema, TTables extends Record<string, any>, TResult> {
135
154
  // Case: new Puri(knex, "users")
136
155
  this.knexQuery = this.knex(tableNameOrSource).from(tableNameOrSource);
137
156
  this.tableSpec = this.safeGetTableSpec(tableNameOrSource);
157
+ this.correlationRegistry.set(tableNameOrSource, {
158
+ kind: "from",
159
+ alias: tableNameOrSource,
160
+ table: tableNameOrSource,
161
+ });
138
162
  } else if (typeof tableNameOrSource === "object") {
139
163
  const entries = Object.entries(tableNameOrSource);
140
164
  if (entries.length !== 1) {
@@ -145,9 +169,11 @@ export class Puri<TSchema, TTables extends Record<string, any>, TResult> {
145
169
  if (typeof source === "string") {
146
170
  this.knexQuery = this.knex(source).from({ [alias]: source });
147
171
  this.tableSpec = this.safeGetTableSpec(source);
172
+ this.correlationRegistry.set(alias, { kind: "from", alias, table: source });
148
173
  } else if (source instanceof Puri) {
149
174
  const subqueryBuilder = source.rawQuery();
150
175
  this.knexQuery = this.knex.from(subqueryBuilder.as(alias));
176
+ this.correlationRegistry.set(alias, { kind: "from", alias, table: null });
151
177
  } else {
152
178
  throw new Error("Invalid table specification");
153
179
  }
@@ -559,19 +585,27 @@ export class Puri<TSchema, TTables extends Record<string, any>, TResult> {
559
585
  // CLEAR
560
586
  clear(statement: ClearStatements): this {
561
587
  this.knexQuery.clear(statement);
588
+ if (statement === "join") {
589
+ this.clearJoinRegistrations();
590
+ }
562
591
  return this;
563
592
  }
564
593
 
565
594
  // knex에 없어서 직접 구현함
566
595
  clearJoin(alias: string): this {
596
+ let removed = false;
567
597
  (this.knexQuery as any)._statements = (this.knexQuery as any)._statements.filter((s: any) => {
568
598
  if ("joinType" in s) {
569
- const [_alias, _table] = Object.entries(s.table)[0];
570
- return _alias !== alias;
599
+ const shouldRemove = this.getKnexJoinAlias(s.table) === alias;
600
+ removed ||= shouldRemove;
601
+ return !shouldRemove;
571
602
  } else {
572
603
  return true;
573
604
  }
574
605
  });
606
+ if (removed && this.correlationRegistry.get(alias)?.kind === "join") {
607
+ this.correlationRegistry.delete(alias);
608
+ }
575
609
  return this;
576
610
  }
577
611
 
@@ -625,6 +659,22 @@ export class Puri<TSchema, TTables extends Record<string, any>, TResult> {
625
659
  return this.__commonJoin("join", tableNameOrSpec, ...args);
626
660
  }
627
661
 
662
+ // ENSURE JOIN: 테이블 + Alias
663
+ ensureJoin<TJoinTable extends keyof TSchema, TJoinAlias extends string>(
664
+ tableSpec: { [K in TJoinAlias]: TJoinTable },
665
+ left: AvailableColumns<TTables>,
666
+ right: `${TJoinAlias}.${ColumnKeys<TSchema[TJoinTable]>}`,
667
+ ): Puri<TSchema, TTables & Record<TJoinAlias, TSchema[TJoinTable]>, TResult>;
668
+ // ENSURE JOIN: 테이블명
669
+ ensureJoin<TJoinTable extends keyof TSchema>(
670
+ tableName: TJoinTable,
671
+ left: AvailableColumns<TTables>,
672
+ right: `${TJoinTable & string}.${ColumnKeys<TSchema[TJoinTable]>}`,
673
+ ): Puri<TSchema, TTables & Record<TJoinTable, TSchema[TJoinTable]>, TResult>;
674
+ ensureJoin(tableNameOrSpec: any, left: string, right: string): any {
675
+ return this.__ensureJoin("join", tableNameOrSpec, left, right);
676
+ }
677
+
628
678
  // LEFT JOIN: 서브쿼리 + Alias
629
679
  leftJoin<TJoinAlias extends string, TSubResult>(
630
680
  tableSpec: { [K in TJoinAlias]: Puri<TSchema, any, TSubResult> },
@@ -676,7 +726,38 @@ export class Puri<TSchema, TTables extends Record<string, any>, TResult> {
676
726
  return this.__commonJoin("leftJoin", tableNameOrSpec, ...args);
677
727
  }
678
728
 
729
+ // ENSURE LEFT JOIN: 테이블 + Alias
730
+ ensureLeftJoin<
731
+ TJoinTable extends keyof TSchema,
732
+ TJoinAlias extends string,
733
+ TLeft extends AvailableColumns<TTables>,
734
+ >(
735
+ tableSpec: { [K in TJoinAlias]: TJoinTable },
736
+ left: TLeft,
737
+ right: `${TJoinAlias}.${ColumnKeys<TSchema[TJoinTable]>}`,
738
+ ): Puri<
739
+ TSchema,
740
+ TTables & Record<TJoinAlias, TSchema[TJoinTable] & LeftJoinMarkerFor<TTables, TLeft>>,
741
+ TResult
742
+ >;
743
+ // ENSURE LEFT JOIN: 테이블명
744
+ ensureLeftJoin<TJoinTable extends keyof TSchema, TLeft extends AvailableColumns<TTables>>(
745
+ tableName: TJoinTable,
746
+ left: TLeft,
747
+ right: `${TJoinTable & string}.${ColumnKeys<TSchema[TJoinTable]>}`,
748
+ ): Puri<
749
+ TSchema,
750
+ TTables & Record<TJoinTable, TSchema[TJoinTable] & LeftJoinMarkerFor<TTables, TLeft>>,
751
+ TResult
752
+ >;
753
+ ensureLeftJoin(tableNameOrSpec: any, left: string, right: string): any {
754
+ return this.__ensureJoin("leftJoin", tableNameOrSpec, left, right);
755
+ }
756
+
679
757
  __commonJoin(joinType: "join" | "leftJoin", tableNameOrSpec: any, ...args: any[]): this {
758
+ const registration = this.createJoinRegistration(joinType, tableNameOrSpec, args);
759
+ this.assertCorrelationAvailable(registration);
760
+
680
761
  if (typeof tableNameOrSpec === "string") {
681
762
  // Case 1: join("posts", ...)
682
763
  const tableName = tableNameOrSpec;
@@ -734,9 +815,154 @@ export class Puri<TSchema, TTables extends Record<string, any>, TResult> {
734
815
  throw new Error("Invalid arguments");
735
816
  }
736
817
 
818
+ this.correlationRegistry.set(registration.alias, registration);
737
819
  return this;
738
820
  }
739
821
 
822
+ private __ensureJoin(
823
+ joinType: "join" | "leftJoin",
824
+ tableNameOrSpec: string | Record<string, string>,
825
+ left: string,
826
+ right: string,
827
+ ): this {
828
+ const requested = this.createJoinRegistration(joinType, tableNameOrSpec, [left, right]);
829
+ if (!requested.reusable) {
830
+ throw new Error(
831
+ `${joinType === "join" ? "ensureJoin" : "ensureLeftJoin"} only supports physical tables with simple column equality conditions.`,
832
+ );
833
+ }
834
+ const existing = this.correlationRegistry.get(requested.alias);
835
+
836
+ if (!existing) {
837
+ return this.__commonJoin(joinType, tableNameOrSpec, left, right);
838
+ }
839
+
840
+ if (this.isSameJoinRegistration(existing, requested)) {
841
+ return this;
842
+ }
843
+
844
+ throw this.createJoinConflictError(existing, requested);
845
+ }
846
+
847
+ private createJoinRegistration(
848
+ joinType: "join" | "leftJoin",
849
+ tableNameOrSpec: any,
850
+ args: any[],
851
+ ): JoinRegistration {
852
+ let alias: string;
853
+ let table: string | null;
854
+
855
+ if (typeof tableNameOrSpec === "string") {
856
+ alias = tableNameOrSpec;
857
+ table = tableNameOrSpec;
858
+ } else if (typeof tableNameOrSpec === "object") {
859
+ const entries = Object.entries(tableNameOrSpec);
860
+ if (entries.length !== 1) {
861
+ throw new Error("Table spec must have exactly one entry");
862
+ }
863
+ assert(entries[0]);
864
+ const [[joinAlias, spec]] = entries;
865
+ alias = joinAlias;
866
+ table = typeof spec === "string" ? spec : null;
867
+ } else {
868
+ throw new Error("Invalid arguments");
869
+ }
870
+
871
+ const [left, right] = args;
872
+ const reusable =
873
+ table !== null && args.length === 2 && typeof left === "string" && typeof right === "string";
874
+
875
+ return {
876
+ kind: "join",
877
+ alias,
878
+ table,
879
+ joinType,
880
+ left: reusable ? left : null,
881
+ right: reusable ? right : null,
882
+ reusable,
883
+ };
884
+ }
885
+
886
+ private assertCorrelationAvailable(requested: JoinRegistration): void {
887
+ const existing = this.correlationRegistry.get(requested.alias);
888
+ if (existing) {
889
+ if (this.isSameJoinRegistration(existing, requested)) {
890
+ throw new Error(
891
+ `Join alias "${requested.alias}" is already registered. Use ensureJoin() or ensureLeftJoin() to reuse an identical join.`,
892
+ );
893
+ }
894
+ throw this.createJoinConflictError(existing, requested);
895
+ }
896
+ }
897
+
898
+ private isSameJoinRegistration(
899
+ existing: CorrelationRegistration,
900
+ requested: JoinRegistration,
901
+ ): existing is JoinRegistration {
902
+ return (
903
+ existing.kind === "join" &&
904
+ existing.reusable &&
905
+ requested.reusable &&
906
+ existing.table === requested.table &&
907
+ existing.joinType === requested.joinType &&
908
+ existing.left === requested.left &&
909
+ existing.right === requested.right
910
+ );
911
+ }
912
+
913
+ private createJoinConflictError(
914
+ existing: CorrelationRegistration,
915
+ requested: JoinRegistration,
916
+ ): Error {
917
+ return new Error(
918
+ [
919
+ `Join alias "${requested.alias}" is already registered with a different definition.`,
920
+ `Existing: ${this.formatCorrelationRegistration(existing)}`,
921
+ `Requested: ${this.formatCorrelationRegistration(requested)}`,
922
+ ].join("\n"),
923
+ );
924
+ }
925
+
926
+ private formatCorrelationRegistration(registration: CorrelationRegistration): string {
927
+ if (registration.kind === "from") {
928
+ return `FROM ${registration.table ?? "subquery"} AS ${registration.alias}`;
929
+ }
930
+
931
+ const joinKeyword = registration.joinType === "join" ? "JOIN" : "LEFT JOIN";
932
+ const source = registration.table ?? "opaque source";
933
+ const condition = registration.reusable
934
+ ? ` ON ${registration.left} = ${registration.right}`
935
+ : " with opaque condition";
936
+ return `${joinKeyword} ${source} AS ${registration.alias}${condition}`;
937
+ }
938
+
939
+ private clearJoinRegistrations(): void {
940
+ for (const [alias, registration] of this.correlationRegistry) {
941
+ if (registration.kind === "join") {
942
+ this.correlationRegistry.delete(alias);
943
+ }
944
+ }
945
+ }
946
+
947
+ private getKnexJoinAlias(table: unknown): string | null {
948
+ if (typeof table === "string") {
949
+ return table;
950
+ }
951
+ if (typeof table === "object" && table !== null) {
952
+ if (
953
+ "_single" in table &&
954
+ typeof table._single === "object" &&
955
+ table._single !== null &&
956
+ "as" in table._single &&
957
+ typeof table._single.as === "string"
958
+ ) {
959
+ return table._single.as;
960
+ }
961
+ return Object.keys(table)[0] ?? null;
962
+ }
963
+ return null;
964
+ }
965
+
740
966
  // WHERE: 객체 - 사용: .where({ "u.id": 1, "u.status": "active" })
741
967
  where(conditions: WhereCondition<TTables>): this;
742
968
  // WHERE: 컬럼 - 사용: .where("u.id", 1), .where("u.id", null)
@@ -1278,6 +1504,7 @@ export class Puri<TSchema, TTables extends Record<string, any>, TResult> {
1278
1504
  // 'dual'은 더미 테이블이며, 바로 아래 줄에서 knexQuery가 덮어씌워집니다.
1279
1505
  const newPuri = new Puri<TSchema, TTables, TResult>(this.knex, "dual");
1280
1506
  newPuri.knexQuery = this.knexQuery.clone();
1507
+ newPuri.correlationRegistry = new Map(this.correlationRegistry);
1281
1508
  return newPuri;
1282
1509
  }
1283
1510
 
@@ -661,3 +661,59 @@ describe("Puri orderBy methods", () => {
661
661
  query.orderBy(["name", "missing_column"]);
662
662
  });
663
663
  });
664
+
665
+ describe("Puri ensureJoin methods", () => {
666
+ it("ensureJoin 이후 alias 컬럼을 타입 안전하게 사용할 수 있다", () => {
667
+ type Query = Puri<MockSchema, { users: MockSchema["users"] }, MockSchema["users"]>;
668
+ const query = {} as Query;
669
+
670
+ const joined = query.ensureJoin(
671
+ { department: "departments" },
672
+ "users.department_id",
673
+ "department.id",
674
+ );
675
+
676
+ expectTypeOf(joined.where("department.name", "개발팀")).toEqualTypeOf(joined);
677
+
678
+ // @ts-expect-error JOIN한 테이블에 없는 컬럼은 사용할 수 없다.
679
+ joined.where("department.email", "dev@example.com");
680
+ });
681
+
682
+ it("ensureLeftJoin은 nullable FK의 객체 타입을 유지한다", () => {
683
+ type Query = Puri<MockSchema, { users: MockSchema["users"] }, MockSchema["users"]>;
684
+ const query = {} as Query;
685
+
686
+ const result = query
687
+ .ensureLeftJoin({ department: "departments" }, "users.department_id", "department.id")
688
+ .select({ department: { id: "department.id" } })
689
+ .first();
690
+
691
+ expectTypeOf(result).resolves.toEqualTypeOf<{
692
+ department: { id: number } | null;
693
+ }>();
694
+ });
695
+
696
+ it("ensureLeftJoin은 non-null FK의 객체를 non-null로 추론한다", () => {
697
+ type Query = Puri<MockSchema, { employees: MockSchema["employees"] }, MockSchema["employees"]>;
698
+ const query = {} as Query;
699
+
700
+ const result = query
701
+ .ensureLeftJoin({ user: "users" }, "employees.user_id", "user.id")
702
+ .select({ user: { id: "user.id" } })
703
+ .first();
704
+
705
+ expectTypeOf(result).resolves.toEqualTypeOf<{
706
+ user: { id: number };
707
+ }>();
708
+ });
709
+
710
+ it("ensureJoin은 callback 형식을 허용하지 않는다", () => {
711
+ type Query = Puri<MockSchema, { users: MockSchema["users"] }, MockSchema["users"]>;
712
+ const query = {} as Query;
713
+
714
+ // @ts-expect-error callback JOIN은 동일성을 판정할 수 없으므로 지원하지 않는다.
715
+ query.ensureJoin({ department: "departments" }, (join) => {
716
+ join.on("users.department_id", "department.id");
717
+ });
718
+ });
719
+ });
@@ -757,7 +757,7 @@ async function generateCreateCode_ColumnAndIndexes(
757
757
 
758
758
  // 컬럼, 인덱스 처리
759
759
  const lines: string[] = [
760
- 'import { Knex } from "knex";',
760
+ 'import type { Knex } from "knex";',
761
761
  "",
762
762
  "export async function up(knex: Knex): Promise<void> {",
763
763
  ...helperDefinitions,
@@ -1083,7 +1083,7 @@ async function generateCreateCode_Foreign(
1083
1083
  }
1084
1084
 
1085
1085
  const lines: string[] = [
1086
- 'import { Knex } from "knex";',
1086
+ 'import type { Knex } from "knex";',
1087
1087
  "",
1088
1088
  "export async function up(knex: Knex): Promise<void> {",
1089
1089
  `return knex.schema.alterTable("${table}", (table) => {`,
@@ -1304,7 +1304,7 @@ async function generateAlterCode_ColumnAndIndexes(
1304
1304
  ];
1305
1305
 
1306
1306
  const lines: string[] = [
1307
- 'import { Knex } from "knex";',
1307
+ 'import type { Knex } from "knex";',
1308
1308
  "",
1309
1309
  "export async function up(knex: Knex): Promise<void> {",
1310
1310
  ...(upBuilderLines.length > 0
@@ -1776,7 +1776,7 @@ async function generateAlterCode_Foreigns(
1776
1776
  }
1777
1777
 
1778
1778
  const lines: string[] = [
1779
- 'import { Knex } from "knex";',
1779
+ 'import type { Knex } from "knex";',
1780
1780
  "",
1781
1781
  "export async function up(knex: Knex): Promise<void> {",
1782
1782
  `return knex.schema.alterTable("${table}", (table) => {`,
@@ -2104,7 +2104,7 @@ async function generatePkTypeChangeMigration(
2104
2104
  }
2105
2105
 
2106
2106
  const lines: string[] = [
2107
- 'import { Knex } from "knex";',
2107
+ 'import type { Knex } from "knex";',
2108
2108
  "",
2109
2109
  "export async function up(knex: Knex): Promise<void> {",
2110
2110
  ...upLines,