sonamu 0.10.3 → 0.10.5

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.
@@ -5,7 +5,7 @@
5
5
  <link rel="icon" type="image/svg+xml" href="/sonamu-ui/setting.svg" />
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
  <title>{{projectName}}: Sonamu UI</title>
8
- <script type="module" crossorigin src="/sonamu-ui/assets/index-CDd6xT-F.js"></script>
8
+ <script type="module" crossorigin src="/sonamu-ui/assets/index-BLX4zN8c.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="/sonamu-ui/assets/index-Dx4ap5i4.css">
10
10
  </head>
11
11
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sonamu",
3
- "version": "0.10.3",
3
+ "version": "0.10.5",
4
4
  "description": "Sonamu — TypeScript Fullstack API Framework",
5
5
  "keywords": [
6
6
  "framework",
@@ -131,8 +131,8 @@
131
131
  "vitest": "^4.1.9",
132
132
  "@sonamu-kit/hmr-hook": "^0.5.1",
133
133
  "@sonamu-kit/hmr-runner": "^0.2.0",
134
- "@sonamu-kit/ts-loader": "^2.2.0",
135
- "@sonamu-kit/tasks": "^0.3.1"
134
+ "@sonamu-kit/tasks": "^0.3.1",
135
+ "@sonamu-kit/ts-loader": "^2.2.0"
136
136
  },
137
137
  "devDependencies": {
138
138
  "@types/bcrypt": "^6",
package/src/bin/cli.ts CHANGED
@@ -197,8 +197,8 @@ async function bootstrap() {
197
197
  stub_entity,
198
198
  scaffold_model,
199
199
  scaffold_model_test,
200
- // scaffold_view_list,
201
- // scaffold_view_form,
200
+ scaffold_view_list,
201
+ scaffold_view_form,
202
202
  cone_gen,
203
203
  sync,
204
204
  build_all,
@@ -983,6 +983,19 @@ async function scaffold_model_test(entityId: string) {
983
983
  });
984
984
  }
985
985
 
986
+ async function scaffold_view_list(entityId: string) {
987
+ await Sonamu.syncer.generateTemplate("view_list", {
988
+ entityId,
989
+ extra: undefined,
990
+ });
991
+ }
992
+
993
+ async function scaffold_view_form(entityId: string) {
994
+ await Sonamu.syncer.generateTemplate("view_form", {
995
+ entityId,
996
+ });
997
+ }
998
+
986
999
  /**
987
1000
  * pnpm sonamu skills sync 하면 실행되는 함수입니다.
988
1001
  * 공식 Skills를 로컬 프로젝트 또는 글로벌 ~/.claude/로 동기화합니다.
@@ -7,6 +7,16 @@ type TestSchema = {
7
7
  users: {
8
8
  id: number;
9
9
  name: string;
10
+ payload: {
11
+ actor: {
12
+ id: string;
13
+ role: string;
14
+ };
15
+ message: string;
16
+ };
17
+ tags: string[];
18
+ state: string;
19
+ readonly __json__: readonly ["payload", "tags", "state"];
10
20
  };
11
21
  };
12
22
 
@@ -33,3 +43,59 @@ describe("Puri locking methods", () => {
33
43
  expect(query).toBe('select * from "users" where "id" = 1 limit 1 for share');
34
44
  });
35
45
  });
46
+
47
+ describe("Puri JSONB containment", () => {
48
+ it("object array scalar and hostile-looking values remain serialized bindings", () => {
49
+ const hostileValue = `x' OR 1=1 --`;
50
+ const objectQuery = usersQuery()
51
+ .whereJsonSupersetOf("users.payload", {
52
+ actor: { id: hostileValue },
53
+ })
54
+ .rawQuery()
55
+ .toSQL();
56
+ const arrayQuery = usersQuery().whereJsonSupersetOf("tags", [hostileValue]).rawQuery().toSQL();
57
+ const scalarQuery = usersQuery().whereJsonSupersetOf("state", hostileValue).rawQuery().toSQL();
58
+
59
+ expect(objectQuery.sql).toBe('select * from "users" where "users"."payload" @> ?');
60
+ expect(objectQuery.bindings).toEqual([
61
+ JSON.stringify({
62
+ actor: { id: hostileValue },
63
+ }),
64
+ ]);
65
+ expect(arrayQuery.sql).toBe('select * from "users" where "tags" @> ?');
66
+ expect(arrayQuery.bindings).toEqual([JSON.stringify([hostileValue])]);
67
+ expect(scalarQuery.sql).toBe('select * from "users" where "state" @> ?');
68
+ expect(scalarQuery.bindings).toEqual([JSON.stringify(hostileValue)]);
69
+
70
+ expect(objectQuery.sql).not.toContain(hostileValue);
71
+ expect(arrayQuery.sql).not.toContain(hostileValue);
72
+ expect(scalarQuery.sql).not.toContain(hostileValue);
73
+ });
74
+
75
+ it("WhereGroup preserves AND OR ordering and undefined is rejected", () => {
76
+ const groupedQuery = usersQuery()
77
+ .where("id", 1)
78
+ .whereGroup((group) => {
79
+ group
80
+ .whereJsonSupersetOf("payload", { actor: { role: "admin" } })
81
+ .orWhereJsonSupersetOf("tags", ["urgent"]);
82
+ })
83
+ .rawQuery()
84
+ .toSQL();
85
+
86
+ expect(groupedQuery.sql).toBe(
87
+ 'select * from "users" where "id" = ? and ("payload" @> ? or "tags" @> ?)',
88
+ );
89
+ expect(groupedQuery.bindings).toEqual([
90
+ 1,
91
+ JSON.stringify({ actor: { role: "admin" } }),
92
+ JSON.stringify(["urgent"]),
93
+ ]);
94
+
95
+ const query = usersQuery();
96
+ expect(() => Reflect.apply(query.whereJsonSupersetOf, query, ["payload", undefined])).toThrow(
97
+ "Puri JSONB containment value must be JSON-serializable; JSON.stringify returned undefined.",
98
+ );
99
+ expect(query.rawQuery().toSQL().sql).toBe('select * from "users"');
100
+ });
101
+ });
@@ -20,6 +20,8 @@ import {
20
20
  type FuzzyOperator,
21
21
  type InsertData,
22
22
  type InsertResult,
23
+ type JsonColumns,
24
+ type JsonSupersetValue,
23
25
  type LeftJoinedMarker,
24
26
  type LeftJoinMarkerFor,
25
27
  type NumericColumns,
@@ -70,6 +72,18 @@ function normalizeFuzzyOperator(operator?: string): FuzzyOperator {
70
72
  return fuzzyOperator;
71
73
  }
72
74
 
75
+ function serializeJsonSupersetValue(value: unknown): string {
76
+ const serialized = JSON.stringify(value);
77
+
78
+ if (serialized === undefined) {
79
+ throw new TypeError(
80
+ "Puri JSONB containment value must be JSON-serializable; JSON.stringify returned undefined.",
81
+ );
82
+ }
83
+
84
+ return serialized;
85
+ }
86
+
73
87
  function normalizeOrderByDirection(direction: PuriOrderByDirection = "asc"): PuriOrderByDirection {
74
88
  if (direction !== "asc" && direction !== "desc") {
75
89
  throw new Error(`Invalid order direction: ${direction}`);
@@ -784,6 +798,15 @@ export class Puri<TSchema, TTables extends Record<string, any>, TResult> {
784
798
  return this as any;
785
799
  }
786
800
 
801
+ // WHERE JSONB SUPERSET (@>)
802
+ whereJsonSupersetOf<TColumn extends JsonColumns<TTables>>(
803
+ column: TColumn,
804
+ value: JsonSupersetValue<ExtractColumnType<TTables, TColumn>>,
805
+ ): this {
806
+ this.knexQuery.whereJsonSupersetOf(column, serializeJsonSupersetValue(value));
807
+ return this;
808
+ }
809
+
787
810
  // WHERE MATCH
788
811
  whereMatch<TColumn extends FulltextColumns<TTables>>(column: TColumn, value: string): this {
789
812
  this.knexQuery.whereRaw(`MATCH (${String(column)}) AGAINST (?)`, [value]);
@@ -1449,6 +1472,23 @@ export class WhereGroup<TTables extends Record<string, any>> {
1449
1472
  return this;
1450
1473
  }
1451
1474
 
1475
+ // WHERE JSONB SUPERSET (@>)
1476
+ whereJsonSupersetOf<TColumn extends JsonColumns<TTables>>(
1477
+ column: TColumn,
1478
+ value: JsonSupersetValue<ExtractColumnType<TTables, TColumn>>,
1479
+ ): this {
1480
+ this.builder.whereJsonSupersetOf(column, serializeJsonSupersetValue(value));
1481
+ return this;
1482
+ }
1483
+
1484
+ orWhereJsonSupersetOf<TColumn extends JsonColumns<TTables>>(
1485
+ column: TColumn,
1486
+ value: JsonSupersetValue<ExtractColumnType<TTables, TColumn>>,
1487
+ ): this {
1488
+ this.builder.orWhereJsonSupersetOf(column, serializeJsonSupersetValue(value));
1489
+ return this;
1490
+ }
1491
+
1452
1492
  // WHERE MATCH
1453
1493
  whereMatch<TColumn extends FulltextColumns<TTables>>(column: TColumn, value: string): this;
1454
1494
  whereMatch(...args: any[]): this {
@@ -1,9 +1,11 @@
1
1
  import { describe, expectTypeOf, it } from "vitest";
2
2
 
3
- import { type Puri } from "./puri";
3
+ import { Puri } from "./puri";
4
4
  import {
5
5
  type AvailableColumns,
6
6
  type ExtractColumnType,
7
+ type JsonColumns,
8
+ type JsonSupersetValue,
7
9
  type LeftJoinedMarker,
8
10
  type ParseSelectObject,
9
11
  } from "./puri.types";
@@ -18,6 +20,28 @@ type MockSchema = {
18
20
  name: string;
19
21
  email: string;
20
22
  department_id: number | null;
23
+ preferences: {
24
+ theme: "light" | "dark";
25
+ notifications: {
26
+ email: boolean;
27
+ sms: boolean;
28
+ };
29
+ labels: string[];
30
+ sessions: {
31
+ id: string;
32
+ active: boolean;
33
+ }[];
34
+ };
35
+ json_tags: string[];
36
+ json_scalar: string;
37
+ nullable_payload: {
38
+ enabled: boolean;
39
+ } | null;
40
+ native_tags: string[];
41
+ created_at: Date;
42
+ embedding: number[];
43
+ readonly __json__: readonly ["preferences", "json_tags", "json_scalar", "nullable_payload"];
44
+ readonly __vector__: readonly ["embedding"];
21
45
  };
22
46
  departments: {
23
47
  id: number;
@@ -458,6 +482,96 @@ describe("AvailableColumns", () => {
458
482
  });
459
483
  });
460
484
 
485
+ describe("JsonColumns and JsonSupersetValue", () => {
486
+ it("JSON metadata selects only JSON columns and preserves recursive RHS types", () => {
487
+ type Tables = { users: MockSchema["users"] };
488
+ type Columns = JsonColumns<Tables>;
489
+ type PreferencesValue = JsonSupersetValue<MockSchema["users"]["preferences"]>;
490
+ type NullablePayloadValue = JsonSupersetValue<MockSchema["users"]["nullable_payload"]>;
491
+
492
+ expectTypeOf<Columns>().toEqualTypeOf<
493
+ | "users.preferences"
494
+ | "users.json_tags"
495
+ | "users.json_scalar"
496
+ | "users.nullable_payload"
497
+ | "preferences"
498
+ | "json_tags"
499
+ | "json_scalar"
500
+ | "nullable_payload"
501
+ >();
502
+
503
+ const partialObject: PreferencesValue = {
504
+ notifications: { email: true },
505
+ sessions: [{ active: false }],
506
+ };
507
+ const arrayRoot: JsonSupersetValue<MockSchema["users"]["json_tags"]> = ["urgent"];
508
+ const scalarRoot: JsonSupersetValue<MockSchema["users"]["json_scalar"]> = "active";
509
+ const nullableColumnValue: NullablePayloadValue = { enabled: true };
510
+
511
+ expectTypeOf(partialObject).toExtend<PreferencesValue>();
512
+ expectTypeOf(arrayRoot).toEqualTypeOf<string[]>();
513
+ expectTypeOf(scalarRoot).toEqualTypeOf<string>();
514
+ expectTypeOf(nullableColumnValue).toExtend<NullablePayloadValue>();
515
+
516
+ // @ts-expect-error nested boolean property must not accept a string.
517
+ const wrongNestedType: PreferencesValue = { notifications: { email: "yes" } };
518
+ // @ts-expect-error array element types are preserved.
519
+ const wrongArrayElement: JsonSupersetValue<MockSchema["users"]["json_tags"]> = [1];
520
+ // @ts-expect-error object array elements retain their recursive partial shape.
521
+ const wrongObjectArrayElement: PreferencesValue = { sessions: [{ active: "yes" }] };
522
+ // @ts-expect-error nullable JSON columns do not accept top-level null as a containment value.
523
+ const nullRoot: NullablePayloadValue = null;
524
+ // @ts-expect-error undefined cannot be serialized as a JSONB containment value.
525
+ const undefinedRoot: PreferencesValue = undefined;
526
+
527
+ const unknownValue: unknown = { notifications: { email: true } };
528
+ // @ts-expect-error unknown must be narrowed before it can be used as a containment value.
529
+ const unknownRoot: PreferencesValue = unknownValue;
530
+
531
+ expectTypeOf(wrongNestedType).toExtend<PreferencesValue>();
532
+ expectTypeOf(wrongArrayElement).toEqualTypeOf<string[]>();
533
+ expectTypeOf(wrongObjectArrayElement).toExtend<PreferencesValue>();
534
+ expectTypeOf(nullRoot).toExtend<NullablePayloadValue>();
535
+ expectTypeOf(undefinedRoot).toExtend<PreferencesValue>();
536
+ expectTypeOf(unknownRoot).toExtend<PreferencesValue>();
537
+
538
+ type Query = Puri<MockSchema, Tables, MockSchema["users"]>;
539
+ const query = {} as Query;
540
+
541
+ expectTypeOf(
542
+ query.whereJsonSupersetOf("preferences", {
543
+ notifications: { sms: false },
544
+ }),
545
+ ).toEqualTypeOf<Query>();
546
+ expectTypeOf(
547
+ query.whereGroup((group) =>
548
+ group
549
+ .whereJsonSupersetOf("users.preferences", { theme: "dark" })
550
+ .orWhereJsonSupersetOf("users.json_tags", ["urgent"]),
551
+ ),
552
+ ).toEqualTypeOf<Query>();
553
+
554
+ // @ts-expect-error ordinary string columns are not JSON columns.
555
+ query.whereJsonSupersetOf("name", "Noa");
556
+ // @ts-expect-error date columns are not JSON columns.
557
+ query.whereJsonSupersetOf("created_at", new Date());
558
+ // @ts-expect-error native PostgreSQL array columns are not JSON columns.
559
+ query.whereJsonSupersetOf("native_tags", ["urgent"]);
560
+ // @ts-expect-error vector columns are not JSON columns.
561
+ query.whereJsonSupersetOf("embedding", [0.1, 0.2]);
562
+ // @ts-expect-error scalar JSON roots retain their scalar type.
563
+ query.whereJsonSupersetOf("json_scalar", { state: "active" });
564
+ // @ts-expect-error top-level null is rejected even when the JSON column itself is nullable.
565
+ query.whereJsonSupersetOf("nullable_payload", null);
566
+ // @ts-expect-error top-level undefined is not JSON-serializable.
567
+ query.whereJsonSupersetOf("preferences", undefined);
568
+ // @ts-expect-error unknown must be narrowed before calling the public method.
569
+ query.whereJsonSupersetOf("preferences", unknownValue);
570
+ // @ts-expect-error top-level OR is intentionally exposed only through whereGroup.
571
+ query.orWhereJsonSupersetOf("preferences", { theme: "light" });
572
+ });
573
+ });
574
+
461
575
  describe("Puri locking methods", () => {
462
576
  it("forUpdate 체이닝 후 first 결과 타입을 유지한다", () => {
463
577
  type Query = Puri<MockSchema, { users: MockSchema["users"] }, MockSchema["users"]>;
@@ -16,6 +16,7 @@ type LeftJoinedKey = "__leftJoined__";
16
16
  type HasDefault = "__hasDefault__";
17
17
  type GeneratedKey = "__generated__";
18
18
  type VectorKey = "__vector__";
19
+ type JsonKey = "__json__";
19
20
  type VirtualQueryKey = "__virtual_query__";
20
21
 
21
22
  type InternalTypeKeys =
@@ -25,6 +26,7 @@ type InternalTypeKeys =
25
26
  | HasDefault
26
27
  | GeneratedKey
27
28
  | VectorKey
29
+ | JsonKey
28
30
  | VirtualQueryKey;
29
31
 
30
32
  // ============================================
@@ -42,6 +44,23 @@ export type VectorColumns<TTables extends Record<string, any>> =
42
44
  }[keyof TTables]
43
45
  | (IsSingleKey<TTables> extends true ? VectorColumnKeys<TTables[keyof TTables]> : never);
44
46
 
47
+ // __json__ 메타데이터에서 JSON 컬럼 추출
48
+ type JsonColumnKeys<T> = T extends { [K in JsonKey]: readonly (infer J)[] } ? J & string : never;
49
+
50
+ export type JsonColumns<TTables extends Record<string, any>> =
51
+ | {
52
+ [TAlias in keyof TTables]: `${TAlias & string}.${JsonColumnKeys<TTables[TAlias]>}`;
53
+ }[keyof TTables]
54
+ | (IsSingleKey<TTables> extends true ? JsonColumnKeys<TTables[keyof TTables]> : never);
55
+
56
+ type JsonSupersetNestedValue<T> = T extends readonly (infer Element)[]
57
+ ? JsonSupersetNestedValue<Element>[]
58
+ : T extends object
59
+ ? { [K in keyof T]?: JsonSupersetNestedValue<T[K]> }
60
+ : T;
61
+
62
+ export type JsonSupersetValue<T> = JsonSupersetNestedValue<NonNullable<T>>;
63
+
45
64
  // 테이블명 타입
46
65
  export type TableName<TSchema> = keyof TSchema & string;
47
66
 
@@ -90,8 +90,7 @@ class PostgreSQLSchemaReaderClass {
90
90
  if (e instanceof Error && e.message.includes("Table not found")) {
91
91
  return null;
92
92
  }
93
- console.error(e);
94
- return null;
93
+ throw e;
95
94
  }
96
95
 
97
96
  // vector 컬럼의 dimensions 조회
@@ -42,6 +42,12 @@ function getSocketPath(): string {
42
42
  : join(homedir(), ".sonamu", `naite-${hash}.sock`);
43
43
  }
44
44
 
45
+ /**
46
+ * 연결 실패가 지속될 때(extension 미실행 등) buffer가 무한히 커지지 않도록
47
+ * 최근 메시지만 이 개수만큼 유지합니다.
48
+ */
49
+ const MAX_BUFFERED_MESSAGES = 1000;
50
+
45
51
  class NaiteReporterClass {
46
52
  private socketPath: string | null = null;
47
53
  private socket: Socket | null = null;
@@ -97,8 +103,11 @@ class NaiteReporterClass {
97
103
  if (this.connected && this.socket) {
98
104
  this.socket.write(msg);
99
105
  } else {
100
- // 연결 대기 중이면 버퍼에 저장
106
+ // 연결 대기 중이면 버퍼에 저장, 상한 초과 시 오래된 메시지부터 폐기
101
107
  this.buffer.push(msg);
108
+ if (this.buffer.length > MAX_BUFFERED_MESSAGES) {
109
+ this.buffer.shift();
110
+ }
102
111
  }
103
112
  }
104
113
 
@@ -171,6 +171,34 @@ db.where("status", "active").orWhereGroup((g) => {
171
171
  });
172
172
  ```
173
173
 
174
+ ### JSONB Containment (`@>`)
175
+
176
+ Use `whereJsonSupersetOf()` to require a JSONB column to contain a JSON value. The column and
177
+ containment value are checked against the generated Entity type, and the value is serialized and
178
+ bound internally.
179
+
180
+ ```typescript
181
+ db.table("products").whereJsonSupersetOf("metadata", { warranty: 2 });
182
+
183
+ // Aliased columns are supported.
184
+ db.table({ p: "products" }).whereJsonSupersetOf("p.metadata", {
185
+ tags: ["featured"],
186
+ });
187
+ ```
188
+
189
+ Inside a WHERE group, both AND and OR variants are available:
190
+
191
+ ```typescript
192
+ db.whereGroup((g) => {
193
+ g.where("status", "active").orWhereJsonSupersetOf("metadata", {
194
+ tags: ["featured"],
195
+ });
196
+ });
197
+ ```
198
+
199
+ Do not call `JSON.stringify()` yourself. For other JSONB operators such as `->>`, `->`, and `?`,
200
+ continue to use parameterized `whereRaw()`.
201
+
174
202
  ## JOIN
175
203
 
176
204
  ```typescript
@@ -62,6 +62,8 @@ async function registerEntityWithFileCustomScalar() {
62
62
  table: `generated_template_file_${entitySeq}`,
63
63
  props: [
64
64
  { name: "id", type: "integer" as const },
65
+ { name: "title", type: "string" as const },
66
+ { name: "created_at", type: "date" as const },
65
67
  {
66
68
  name: "file_props",
67
69
  type: "json" as const,
@@ -70,7 +72,7 @@ async function registerEntityWithFileCustomScalar() {
70
72
  ],
71
73
  indexes: [],
72
74
  subsets: {
73
- A: ["id", "file_props"],
75
+ A: ["id", "title", "created_at", "file_props"],
74
76
  },
75
77
  enums: {},
76
78
  };
@@ -82,7 +84,7 @@ async function registerEntityWithFileCustomScalar() {
82
84
  image: SonamuFileSchema.nullable(),
83
85
  });
84
86
 
85
- return { typeId };
87
+ return { entity: registeredEntity, typeId };
86
88
  }
87
89
 
88
90
  describe("Template__generated searchText", () => {
@@ -151,3 +153,16 @@ describe("Template__generated searchText", () => {
151
153
  );
152
154
  });
153
155
  });
156
+
157
+ describe("Template__generated JSON metadata", () => {
158
+ test("only type=json properties appear in readonly __json__", async () => {
159
+ const { entity } = await registerEntityWithFileCustomScalar();
160
+ const template = new Template__generated();
161
+
162
+ const source = template.getBaseSchemaSourceCode(entity).lines.join("\n");
163
+
164
+ expect(source).toContain('readonly __json__: readonly ["file_props"],');
165
+ expect(source).not.toContain('readonly __json__: readonly ["title"');
166
+ expect(source).not.toContain('readonly __json__: readonly ["created_at"');
167
+ });
168
+ });
@@ -215,6 +215,14 @@ export class Template__generated extends Template {
215
215
  .filter((prop) => prop.type === "vector" || prop.type === "vector[]")
216
216
  .map((prop) => prop.name);
217
217
 
218
+ /**
219
+ * JSON props
220
+ * - json 타입인 컬럼
221
+ */
222
+ const jsonColumns = entity.props
223
+ .filter((prop) => prop.type === "json")
224
+ .map((prop) => prop.name);
225
+
218
226
  /**
219
227
  * generated props
220
228
  * - generated 속성이 있는 컬럼 (INSERT/UPDATE 시 값 제공 불가)
@@ -232,7 +240,8 @@ export class Template__generated extends Template {
232
240
  virtualQueryProps.length > 0 ||
233
241
  hasDefaultColumns.length > 0 ||
234
242
  generatedColumns.length > 0 ||
235
- hasVectorColumns.length > 0;
243
+ hasVectorColumns.length > 0 ||
244
+ jsonColumns.length > 0;
236
245
 
237
246
  const lines = [
238
247
  `export const ${schemaName} = ${schemaBody};`,
@@ -264,6 +273,11 @@ export class Template__generated extends Template {
264
273
  ? `readonly __vector__: readonly [${hasVectorColumns
265
274
  .map((col) => `"${col}"`)
266
275
  .join(", ")}],`
276
+ : "") +
277
+ (jsonColumns.length > 0
278
+ ? `readonly __json__: readonly [${jsonColumns
279
+ .map((col) => `"${col}"`)
280
+ .join(", ")}],`
267
281
  : "")
268
282
  }}`
269
283
  : "") +