sonamu 0.10.2 → 0.10.4

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-DFStGyd0.js"></script>
8
+ <script type="module" crossorigin src="/sonamu-ui/assets/index-CDd6xT-F.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.2",
3
+ "version": "0.10.4",
4
4
  "description": "Sonamu — TypeScript Fullstack API Framework",
5
5
  "keywords": [
6
6
  "framework",
@@ -130,8 +130,8 @@
130
130
  "vite": "8.0.16",
131
131
  "vitest": "^4.1.9",
132
132
  "@sonamu-kit/hmr-hook": "^0.5.1",
133
- "@sonamu-kit/hmr-runner": "^0.2.0",
134
133
  "@sonamu-kit/tasks": "^0.3.1",
134
+ "@sonamu-kit/hmr-runner": "^0.2.0",
135
135
  "@sonamu-kit/ts-loader": "^2.2.0"
136
136
  },
137
137
  "devDependencies": {
@@ -1,11 +1,109 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { type FastifyRequest } from "fastify";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { type WebSocket } from "ws";
4
+ import { z } from "zod";
2
5
 
3
- import { type AnyWebSocketConnection } from "../../stream/ws";
6
+ import { type AnyWebSocketConnection, WebSocketRuntime } from "../../stream/ws";
4
7
  import { type WebSocketTelemetryConnectionContext } from "../../stream/ws-telemetry";
8
+ import { type SonamuFastifyConfig } from "../config";
5
9
  import { type WebSocketContext } from "../context";
10
+ import { type ExtendedApi } from "../decorators";
6
11
  import { Sonamu } from "../sonamu";
7
12
 
8
13
  describe("Sonamu websocket context scoping", () => {
14
+ it("웹소켓 컨텍스트를 생성한 뒤 해당 컨텍스트 안에서 guard를 실행한다", async () => {
15
+ const executionOrder: string[] = [];
16
+ const rawWs = {
17
+ id: "ws-guard",
18
+ namespace: "chat",
19
+ transport: "ws" as const,
20
+ closed: false,
21
+ publishUntyped() {},
22
+ close() {},
23
+ onClose() {},
24
+ onMessage() {},
25
+ publish() {},
26
+ waitForClose() {
27
+ return Promise.resolve();
28
+ },
29
+ join() {},
30
+ leave() {},
31
+ setUserId() {},
32
+ clearUserId() {},
33
+ } satisfies AnyWebSocketConnection;
34
+ const runtime = new WebSocketRuntime({ nodeId: "websocket-guard-test" });
35
+ const originalRuntime = Reflect.get(Sonamu, "_websocketRuntime");
36
+ const originalSyncer = Reflect.get(Sonamu, "_syncer");
37
+ let contextCreated: WebSocketContext | null = null;
38
+ let guardContext: WebSocketContext | null = null;
39
+
40
+ vi.spyOn(runtime, "registerConnection").mockReturnValue(rawWs);
41
+ vi.spyOn(runtime, "activateConnection").mockImplementation(() => {
42
+ executionOrder.push("activate");
43
+ });
44
+ vi.spyOn(Sonamu, "createWebSocketContext").mockImplementation(async (_config, request, ws) => {
45
+ executionOrder.push("context");
46
+ contextCreated = {
47
+ transport: "ws",
48
+ request,
49
+ headers: request.headers,
50
+ ws,
51
+ naiteStore: new Map(),
52
+ locale: "ko",
53
+ user: null,
54
+ session: null,
55
+ };
56
+ return contextCreated;
57
+ });
58
+ vi.spyOn(Sonamu, "invokeModelMethod").mockImplementation(async () => {
59
+ executionOrder.push("handler");
60
+ });
61
+
62
+ const config = {
63
+ guardHandler() {
64
+ executionOrder.push("guard");
65
+ guardContext = Sonamu.getContext<WebSocketContext>();
66
+ },
67
+ } as SonamuFastifyConfig;
68
+ const api: ExtendedApi = {
69
+ modelName: "ChatFrame",
70
+ methodName: "subscribe",
71
+ path: "/chat/subscribe",
72
+ options: { guards: ["user"] },
73
+ websocketOptions: {
74
+ outEvents: z.object({}),
75
+ inEvents: z.object({}),
76
+ },
77
+ typeParameters: [],
78
+ parameters: [],
79
+ returnType: "void",
80
+ };
81
+ const request = {
82
+ headers: {},
83
+ query: {},
84
+ } as FastifyRequest;
85
+ const createWebSocketHandler: (
86
+ api: ExtendedApi,
87
+ config: SonamuFastifyConfig,
88
+ ) => (connection: { socket: WebSocket }, request: FastifyRequest) => Promise<void> =
89
+ Reflect.get(Sonamu, "createWebSocketHandler");
90
+
91
+ try {
92
+ Sonamu.websocketRuntime = runtime;
93
+ Reflect.set(Sonamu, "_syncer", { types: {} });
94
+
95
+ await createWebSocketHandler.call(Sonamu, api, config)({ socket: {} as WebSocket }, request);
96
+
97
+ expect(executionOrder).toEqual(["context", "guard", "activate", "handler"]);
98
+ expect(guardContext).toBe(contextCreated);
99
+ expect(guardContext?.transport).toBe("ws");
100
+ } finally {
101
+ Reflect.set(Sonamu, "_websocketRuntime", originalRuntime);
102
+ Reflect.set(Sonamu, "_syncer", originalSyncer);
103
+ vi.restoreAllMocks();
104
+ }
105
+ });
106
+
9
107
  it("restores websocket context inside deferred message handlers", async () => {
10
108
  const messageHandlers = new Map<
11
109
  string,
package/src/api/sonamu.ts CHANGED
@@ -1095,9 +1095,9 @@ class SonamuClass {
1095
1095
  }
1096
1096
 
1097
1097
  // WS route 핸들러의 실행 순서를 고정함:
1098
- // 1) guard를 connection 등록 이전에 돌려 인증 실패 시 부분 등록 상태를 남기지 않음
1099
- // 2) query param 파싱도 activation 전에 끝내 handshake 실패가 registry에 노출되지 않게 함
1100
- // 3) `active: false`로 먼저 등록하고, context 준비가 끝난 뒤 `activate()`해 브로드캐스트가 초기화 중간 상태를 보지 못하게 함
1098
+ // 1) query param 파싱을 activation 전에 끝내 handshake 실패가 registry에 노출되지 않게 함
1099
+ // 2) `active: false`로 먼저 등록한 뒤 context 안에서 guard를 실행해 인증 정보를 참조할 수 있게 함
1100
+ // 3) context/guard 준비가 끝난 뒤 `activate()`해 브로드캐스트가 초기화 중간 상태를 보지 못하게 함
1101
1101
  // 에러 발생 시에는 resolveWebSocketCloseDescriptor 정책에 따라 close code를 매핑함
1102
1102
  private createWebSocketHandler(api: ExtendedApi, config: SonamuFastifyConfig) {
1103
1103
  return async (
@@ -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
 
@@ -340,10 +359,15 @@ type GeneratedKeys<T> = T extends { __generated__: readonly (infer K)[] }
340
359
  ? Extract<K, keyof PuriTable<T>>
341
360
  : never;
342
361
 
343
- // Insert 타입: 메타데이터 제거 후, __hasDefault__ 컬럼들만 optional로 처리, __generated__ 컬럼은 완전히 제외
362
+ // __virtual_query__에 포함된 키들 (INSERT 시 제외해야 함)
363
+ type VirtualQueryKeys<T> = T extends { __virtual_query__: readonly (infer K)[] }
364
+ ? Extract<K, keyof PuriTable<T>>
365
+ : never;
366
+
367
+ // Insert 타입: 메타데이터 제거 후, __hasDefault__ 컬럼들만 optional로 처리, generated/virtualQuery 컬럼은 완전히 제외
344
368
  export type InsertData<T> = Omit<
345
369
  PuriTable<T>,
346
- InternalTypeKeys | HasDefaultKeys<T> | GeneratedKeys<T>
370
+ InternalTypeKeys | HasDefaultKeys<T> | GeneratedKeys<T> | VirtualQueryKeys<T>
347
371
  > & {
348
372
  [K in HasDefaultKeys<T>]?: PuriTable<T>[K];
349
373
  };
@@ -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
+ });
@@ -13,11 +13,20 @@ type GeneratedTemplateRow = {
13
13
  __generated__: readonly ["slug", "search_text"];
14
14
  };
15
15
 
16
+ type VirtualQueryTemplateRow = {
17
+ id: number;
18
+ title: string;
19
+ required_virtual_value: string;
20
+ nullable_virtual_value: string | null;
21
+ __hasDefault__: readonly ["id"];
22
+ __virtual_query__: readonly ["required_virtual_value", "nullable_virtual_value"];
23
+ };
24
+
16
25
  describe("generated template __generated__ metadata", () => {
17
26
  it("searchText와 기존 generated 컬럼을 write payload에서 제외해야 한다", () => {
18
27
  type Result = InsertData<GeneratedTemplateRow>;
19
28
 
20
- expectTypeOf<Result>().toEqualTypeOf<{
29
+ expectTypeOf<Result>().branded.toEqualTypeOf<{
21
30
  id?: number;
22
31
  title: string;
23
32
  code: string;
@@ -45,3 +54,14 @@ describe("generated template __generated__ metadata", () => {
45
54
  }>();
46
55
  });
47
56
  });
57
+
58
+ describe("generated template __virtual_query__ metadata", () => {
59
+ it("virtualQuery 컬럼을 nullability와 무관하게 write payload에서 제외해야 한다", () => {
60
+ type Result = InsertData<VirtualQueryTemplateRow>;
61
+
62
+ expectTypeOf<Result>().branded.toEqualTypeOf<{
63
+ id?: number;
64
+ title: string;
65
+ }>();
66
+ });
67
+ });