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.
- package/dist/api/sonamu.js +1 -1
- package/dist/database/puri.d.ts +4 -1
- package/dist/database/puri.d.ts.map +1 -1
- package/dist/database/puri.js +18 -1
- package/dist/database/puri.types.d.ts +16 -2
- package/dist/database/puri.types.d.ts.map +1 -1
- package/dist/database/puri.types.js +1 -1
- package/dist/migration/postgresql-schema-reader.d.ts.map +1 -1
- package/dist/migration/postgresql-schema-reader.js +2 -3
- package/dist/naite/naite-reporter.d.ts.map +1 -1
- package/dist/naite/naite-reporter.js +10 -2
- package/dist/template/implementations/generated.template.d.ts.map +1 -1
- package/dist/template/implementations/generated.template.js +8 -3
- package/dist/ui-web/assets/index-CDd6xT-F.js +170 -0
- package/dist/ui-web/index.html +1 -1
- package/package.json +2 -2
- package/src/api/__tests__/sonamu.websocket.test.ts +100 -2
- package/src/api/sonamu.ts +3 -3
- package/src/database/__tests__/puri.test.ts +66 -0
- package/src/database/puri.ts +40 -0
- package/src/database/puri.types.test-d.ts +115 -1
- package/src/database/puri.types.ts +26 -2
- package/src/migration/postgresql-schema-reader.ts +1 -2
- package/src/naite/naite-reporter.ts +10 -1
- package/src/skills/sonamu/puri.md +28 -0
- package/src/template/__tests__/generated.template.search-text.test.ts +17 -2
- package/src/template/generated.template.test-d.ts +21 -1
- package/src/template/implementations/generated.template.ts +15 -1
- package/dist/ui-web/assets/index-DFStGyd0.js +0 -202
package/dist/ui-web/index.html
CHANGED
|
@@ -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-
|
|
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.
|
|
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 {
|
|
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)
|
|
1099
|
-
// 2)
|
|
1100
|
-
// 3)
|
|
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
|
+
});
|
package/src/database/puri.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
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
|
};
|
|
@@ -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
|
+
});
|