create-entity-server 0.0.15 → 0.0.23

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 (42) hide show
  1. package/bin/create.js +15 -7
  2. package/package.json +1 -1
  3. package/template/.env.example +8 -7
  4. package/template/configs/database.json +173 -10
  5. package/template/entities/Account/account_audit.json +4 -5
  6. package/template/entities/System/system_audit_log.json +14 -8
  7. package/template/samples/README.md +28 -22
  8. package/template/samples/browser/entity-server-client.js +453 -0
  9. package/template/samples/browser/example.html +498 -0
  10. package/template/samples/entities/02_types_and_defaults.json +15 -16
  11. package/template/samples/entities/04_fk_and_composite_unique.json +0 -2
  12. package/template/samples/entities/05_cache.json +9 -8
  13. package/template/samples/entities/06_history_and_hard_delete.json +27 -9
  14. package/template/samples/entities/07_license_scope.json +40 -31
  15. package/template/samples/entities/09_hook_entity.json +0 -6
  16. package/template/samples/entities/10_hook_submit_delete.json +5 -2
  17. package/template/samples/entities/11_hook_webhook.json +9 -7
  18. package/template/samples/entities/12_hook_push.json +3 -3
  19. package/template/samples/entities/13_read_only.json +13 -10
  20. package/template/samples/entities/15_reset_defaults.json +0 -1
  21. package/template/samples/entities/16_isolated_license.json +62 -0
  22. package/template/samples/entities/README.md +36 -39
  23. package/template/samples/flutter/lib/entity_server_client.dart +170 -48
  24. package/template/samples/java/EntityServerClient.java +208 -61
  25. package/template/samples/java/EntityServerExample.java +4 -3
  26. package/template/samples/kotlin/EntityServerClient.kt +175 -45
  27. package/template/samples/node/src/EntityServerClient.js +232 -59
  28. package/template/samples/node/src/example.js +9 -9
  29. package/template/samples/php/ci4/Config/EntityServer.php +0 -1
  30. package/template/samples/php/ci4/Libraries/EntityServer.php +206 -53
  31. package/template/samples/php/laravel/Services/EntityServerService.php +190 -41
  32. package/template/samples/python/entity_server.py +181 -68
  33. package/template/samples/python/example.py +7 -6
  34. package/template/samples/react/src/example.tsx +41 -25
  35. package/template/samples/swift/EntityServerClient.swift +143 -37
  36. package/template/scripts/run.ps1 +12 -3
  37. package/template/scripts/run.sh +12 -8
  38. package/template/scripts/update-server.ps1 +68 -2
  39. package/template/scripts/update-server.sh +59 -2
  40. package/template/samples/entities/order_notification.json +0 -51
  41. package/template/samples/react/src/api/entityServerClient.ts +0 -413
  42. package/template/samples/react/src/hooks/useEntity.ts +0 -173
@@ -19,6 +19,56 @@ REPO="ehfuse/entity-server"
19
19
  BINARIES=("entity-server" "entity-cli")
20
20
  DIST_DIRS=("scripts" "samples")
21
21
 
22
+ _find_running_pid() {
23
+ local pid_file="$PROJECT_ROOT/.run/entity-server.pid"
24
+ if [ -f "$pid_file" ]; then
25
+ local pid
26
+ pid=$(cat "$pid_file" 2>/dev/null)
27
+ if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
28
+ echo "$pid"
29
+ return 0
30
+ fi
31
+ fi
32
+
33
+ pgrep -f "${PROJECT_ROOT}/(bin/)?entity-server( |$)" | head -n 1
34
+ }
35
+
36
+ _ensure_server_stopped() {
37
+ local pid
38
+ pid="$(_find_running_pid)"
39
+
40
+ if [ -z "$pid" ]; then
41
+ return 0
42
+ fi
43
+
44
+ echo ""
45
+ echo "⚠️ 현재 Entity Server가 실행 중입니다."
46
+ ps -p "$pid" -o pid,etime,cmd --no-headers 2>/dev/null || true
47
+ echo ""
48
+ read -r -p "업데이트를 위해 서버를 중지할까요? [y/N]: " input
49
+ input=$(echo "$input" | tr '[:upper:]' '[:lower:]')
50
+
51
+ if [ "$input" != "y" ] && [ "$input" != "yes" ]; then
52
+ echo "❌ 업데이트를 취소했습니다."
53
+ exit 1
54
+ fi
55
+
56
+ if [ -x "$PROJECT_ROOT/scripts/run.sh" ]; then
57
+ "$PROJECT_ROOT/scripts/run.sh" stop || true
58
+ else
59
+ kill "$pid" 2>/dev/null || true
60
+ fi
61
+
62
+ sleep 0.2
63
+ pid="$(_find_running_pid)"
64
+ if [ -n "$pid" ]; then
65
+ echo "❌ 서버 중지에 실패했습니다. 업데이트를 중단합니다."
66
+ exit 1
67
+ fi
68
+
69
+ echo "✅ 서버 중지 완료"
70
+ }
71
+
22
72
  # ── 플랫폼 감지 ───────────────────────────────────────────────────────────────
23
73
 
24
74
  OS="$(uname -s)"
@@ -46,7 +96,10 @@ esac
46
96
  # ── 현재 버전 확인 ────────────────────────────────────────────────────────────
47
97
 
48
98
  _current_ver() {
49
- local bin="$PROJECT_ROOT/entity-server"
99
+ local bin="$PROJECT_ROOT/bin/entity-server"
100
+ if [ ! -x "$bin" ] && [ -x "$PROJECT_ROOT/entity-server" ]; then
101
+ bin="$PROJECT_ROOT/entity-server"
102
+ fi
50
103
  if [ -x "$bin" ]; then
51
104
  "$bin" --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "(알 수 없음)"
52
105
  else
@@ -100,15 +153,19 @@ _install() {
100
153
  local current_ver
101
154
  current_ver="$(_current_ver)"
102
155
 
156
+ _ensure_server_stopped
157
+
103
158
  echo ""
104
159
  echo "📦 entity-server v${target_ver} 다운로드 중... (${PLATFORM}-${ARCH_TAG})"
105
160
  echo ""
106
161
 
107
162
  # 바이너리 다운로드
163
+ mkdir -p "$PROJECT_ROOT/bin"
164
+
108
165
  for BIN in "${BINARIES[@]}"; do
109
166
  local file="${BIN}-${PLATFORM}-${ARCH_TAG}"
110
167
  local url="https://github.com/${REPO}/releases/download/v${target_ver}/${file}"
111
- local dest="$PROJECT_ROOT/$BIN"
168
+ local dest="$PROJECT_ROOT/bin/$BIN"
112
169
 
113
170
  printf " ↓ %-32s" "$file"
114
171
  if _download "$url" "$dest" 2>/dev/null; then
@@ -1,51 +0,0 @@
1
- {
2
- "name": "order_notification",
3
- "description": "주문 알림 예제 — 주문 생성 시 고객에게 푸시 전송",
4
- "index": {
5
- "customer_seq": {
6
- "comment": "고객 user seq",
7
- "required": true
8
- },
9
- "order_number": {
10
- "comment": "주문 번호",
11
- "required": true
12
- },
13
- "status": {
14
- "comment": "주문 상태",
15
- "type": ["placed", "confirmed", "shipped", "delivered", "cancelled"],
16
- "default": "placed"
17
- },
18
- "total_amount": {
19
- "comment": "주문 금액",
20
- "type": "uint"
21
- }
22
- },
23
- "hooks": {
24
- "after_insert": [
25
- {
26
- "type": "push",
27
- "target_user_field": "customer_seq",
28
- "title": "주문 접수",
29
- "push_body": "주문 #${new.order_number}이 접수되었습니다. 금액: ${new.total_amount}원",
30
- "push_data": {
31
- "action": "order_created",
32
- "order_seq": "${new.seq}",
33
- "order_number": "${new.order_number}"
34
- }
35
- }
36
- ],
37
- "after_update": [
38
- {
39
- "type": "push",
40
- "target_user_field": "customer_seq",
41
- "title": "주문 상태 변경",
42
- "push_body": "주문 #${new.order_number} 상태: ${new.status}",
43
- "push_data": {
44
- "action": "order_updated",
45
- "order_seq": "${new.seq}",
46
- "status": "${new.status}"
47
- }
48
- }
49
- ]
50
- }
51
- }
@@ -1,413 +0,0 @@
1
- /**
2
- * Entity Server API 클라이언트 (React / 브라우저)
3
- *
4
- * 브라우저 환경에서는 HMAC secret을 노출할 수 없으므로 JWT Bearer 토큰 방식을 사용합니다.
5
- *
6
- * 패킷 암호화 지원:
7
- * 서버가 application/octet-stream 으로 응답하면 자동으로 복호화합니다.
8
- * 복호화 키: sha256(access_token)
9
- * 의존성: @noble/ciphers, @noble/hashes (npm install @noble/ciphers @noble/hashes)
10
- *
11
- * 환경변수 (Vite):
12
- * VITE_ENTITY_SERVER_URL=http://localhost:47200
13
- * VITE_PACKET_MAGIC_LEN=4 (서버 packet_magic_len 과 동일하게)
14
- *
15
- * 트랜잭션 사용 예:
16
- * await es.transStart();
17
- * try {
18
- * const orderRef = await es.submit('order', { user_seq: 1, total: 9900 }); // seq: "$tx.0"
19
- * await es.submit('order_item', { order_seq: orderRef.seq, item_seq: 5 }); // "$tx.0" 자동 치환
20
- * const result = await es.transCommit();
21
- * const orderSeq = result.results[0].seq; // 실제 seq
22
- * } catch (e) {
23
- * await es.transRollback();
24
- * }
25
- */
26
-
27
- import { xchacha20_poly1305 } from "@noble/ciphers/chacha";
28
- import { sha256 } from "@noble/hashes/sha2";
29
-
30
- export interface EntityListParams {
31
- page?: number;
32
- limit?: number;
33
- orderBy?: string;
34
- }
35
-
36
- export interface EntityQueryFilter {
37
- field: string;
38
- op: "eq" | "ne" | "gt" | "gte" | "lt" | "lte" | "like" | "in";
39
- value: unknown;
40
- }
41
-
42
- export interface RegisterPushDeviceOptions {
43
- platform?: string;
44
- deviceType?: string;
45
- browser?: string;
46
- browserVersion?: string;
47
- pushEnabled?: boolean;
48
- transactionId?: string;
49
- }
50
-
51
- export class EntityServerClient {
52
- private baseUrl: string;
53
- private token: string;
54
- private magicLen: number;
55
- private activeTxId: string | null = null;
56
-
57
- constructor(baseUrl?: string, token?: string) {
58
- this.baseUrl = (
59
- baseUrl ??
60
- (import.meta as unknown as Record<string, Record<string, string>>)
61
- .env?.VITE_ENTITY_SERVER_URL ??
62
- "http://localhost:47200"
63
- ).replace(/\/$/, "");
64
- this.token = token ?? "";
65
- const envMagic = (
66
- import.meta as unknown as Record<string, Record<string, string>>
67
- ).env?.VITE_PACKET_MAGIC_LEN;
68
- this.magicLen = envMagic ? Number(envMagic) : 4;
69
- }
70
-
71
- setToken(token: string): void {
72
- this.token = token;
73
- }
74
-
75
- // ─── 인증 ────────────────────────────────────────────────────────────────
76
-
77
- async login(
78
- email: string,
79
- password: string,
80
- ): Promise<{
81
- access_token: string;
82
- refresh_token: string;
83
- expires_in: number;
84
- }> {
85
- const data = await this.request<{
86
- data: {
87
- access_token: string;
88
- refresh_token: string;
89
- expires_in: number;
90
- };
91
- }>("POST", "/v1/auth/login", { email, passwd: password }, false);
92
- this.token = data.data.access_token;
93
- return data.data;
94
- }
95
-
96
- async refreshToken(
97
- refreshToken: string,
98
- ): Promise<{ access_token: string; expires_in: number }> {
99
- const data = await this.request<{
100
- data: { access_token: string; expires_in: number };
101
- }>("POST", "/v1/auth/refresh", { refresh_token: refreshToken }, false);
102
- this.token = data.data.access_token;
103
- return data.data;
104
- }
105
-
106
- // ─── 트랜잭션 ──────────────────────────────────────────────────────────────
107
-
108
- /**
109
- * 트랜잭션 시작 — 서버에 트랜잭션 큐를 등록하고 transaction_id 를 반환합니다.
110
- * 이후 submit / delete 가 서버 큐에 쌓이고 transCommit() 시 일괄 처리됩니다.
111
- */
112
- async transStart(): Promise<string> {
113
- const res = await this.request<{ ok: boolean; transaction_id: string }>(
114
- "POST",
115
- "/v1/transaction/start",
116
- undefined,
117
- false,
118
- );
119
- this.activeTxId = res.transaction_id;
120
- return this.activeTxId;
121
- }
122
-
123
- /**
124
- * 트랜잭션 단위로 변경사항을 롤백합니다.
125
- * transactionId 생략 시 transStart() 로 시작한 활성 트랜잭션을 사용합니다.
126
- */
127
- transRollback(transactionId?: string): Promise<{ ok: boolean }> {
128
- const txId = transactionId ?? this.activeTxId;
129
- if (!txId)
130
- return Promise.reject(
131
- new Error("No active transaction. Call transStart() first."),
132
- );
133
- this.activeTxId = null;
134
- return this.request("POST", `/v1/transaction/rollback/${txId}`);
135
- }
136
-
137
- /**
138
- * 트랜잭션 커밋 — 서버 큐에 쌓인 작업을 단일 DB 트랜잭션으로 일괄 처리합니다.
139
- * transactionId 생략 시 transStart() 로 시작한 활성 트랜잭션을 사용합니다.
140
- */
141
- transCommit(
142
- transactionId?: string,
143
- ): Promise<{ ok: boolean; results: unknown[] }> {
144
- const txId = transactionId ?? this.activeTxId;
145
- if (!txId)
146
- return Promise.reject(
147
- new Error("No active transaction. Call transStart() first."),
148
- );
149
- this.activeTxId = null;
150
- return this.request("POST", `/v1/transaction/commit/${txId}`);
151
- }
152
-
153
- // ─── CRUD ────────────────────────────────────────────────────────────────
154
-
155
- get<T = unknown>(
156
- entity: string,
157
- seq: number,
158
- ): Promise<{ ok: boolean; data: T }> {
159
- return this.request("GET", `/v1/entity/${entity}/${seq}`);
160
- }
161
-
162
- list<T = unknown>(
163
- entity: string,
164
- params: EntityListParams = {},
165
- ): Promise<{ ok: boolean; data: T[]; total: number }> {
166
- const q = buildQuery({ page: 1, limit: 20, ...params });
167
- return this.request("GET", `/v1/entity/${entity}/list?${q}`);
168
- }
169
-
170
- count(entity: string): Promise<{ ok: boolean; count: number }> {
171
- return this.request("GET", `/v1/entity/${entity}/count`);
172
- }
173
-
174
- query<T = unknown>(
175
- entity: string,
176
- filter: EntityQueryFilter[] = [],
177
- params: EntityListParams = {},
178
- ): Promise<{ ok: boolean; data: T[]; total: number }> {
179
- const q = buildQuery({ page: 1, limit: 20, ...params });
180
- return this.request("POST", `/v1/entity/${entity}/query?${q}`, filter);
181
- }
182
-
183
- submit<T = unknown>(
184
- entity: string,
185
- data: Record<string, unknown>,
186
- opts: { transactionId?: string } = {},
187
- ): Promise<{ ok: boolean; seq?: number; data?: T }> {
188
- const txId = opts.transactionId ?? this.activeTxId;
189
- const extra = txId ? { "X-Transaction-ID": txId } : {};
190
- return this.request(
191
- "POST",
192
- `/v1/entity/${entity}/submit`,
193
- data,
194
- true,
195
- extra,
196
- );
197
- }
198
-
199
- delete(
200
- entity: string,
201
- seq: number,
202
- opts: { transactionId?: string; hard?: boolean } = {},
203
- ): Promise<{ ok: boolean }> {
204
- const q = opts.hard ? "?hard=true" : "";
205
- const txId = opts.transactionId ?? this.activeTxId;
206
- const extra = txId ? { "X-Transaction-ID": txId } : {};
207
- return this.request(
208
- "DELETE",
209
- `/v1/entity/${entity}/delete/${seq}${q}`,
210
- undefined,
211
- true,
212
- extra,
213
- );
214
- }
215
-
216
- history<T = unknown>(
217
- entity: string,
218
- seq: number,
219
- params: EntityListParams = {},
220
- ): Promise<{ ok: boolean; data: T[] }> {
221
- const q = buildQuery({ page: 1, limit: 50, ...params });
222
- return this.request("GET", `/v1/entity/${entity}/history/${seq}?${q}`);
223
- }
224
-
225
- rollback(entity: string, historySeq: number): Promise<{ ok: boolean }> {
226
- return this.request(
227
- "POST",
228
- `/v1/entity/${entity}/rollback/${historySeq}`,
229
- );
230
- }
231
-
232
- push<T = unknown>(
233
- pushEntity: string,
234
- payload: Record<string, unknown>,
235
- opts: { transactionId?: string } = {},
236
- ): Promise<{ ok: boolean; seq?: number; data?: T }> {
237
- return this.submit<T>(pushEntity, payload, opts);
238
- }
239
-
240
- pushLogList<T = unknown>(
241
- params: EntityListParams = {},
242
- ): Promise<{ ok: boolean; data: T[]; total: number }> {
243
- return this.list<T>("push_log", params);
244
- }
245
-
246
- registerPushDevice<T = unknown>(
247
- accountSeq: number,
248
- deviceId: string,
249
- pushToken: string,
250
- opts: RegisterPushDeviceOptions = {},
251
- ): Promise<{ ok: boolean; seq?: number; data?: T }> {
252
- const {
253
- platform,
254
- deviceType,
255
- browser,
256
- browserVersion,
257
- pushEnabled = true,
258
- transactionId,
259
- } = opts;
260
-
261
- return this.submit<T>(
262
- "account_device",
263
- {
264
- id: deviceId,
265
- account_seq: accountSeq,
266
- push_token: pushToken,
267
- push_enabled: pushEnabled,
268
- ...(platform ? { platform } : {}),
269
- ...(deviceType ? { device_type: deviceType } : {}),
270
- ...(browser ? { browser } : {}),
271
- ...(browserVersion ? { browser_version: browserVersion } : {}),
272
- },
273
- { transactionId },
274
- );
275
- }
276
-
277
- updatePushDeviceToken<T = unknown>(
278
- deviceSeq: number,
279
- pushToken: string,
280
- opts: { pushEnabled?: boolean; transactionId?: string } = {},
281
- ): Promise<{ ok: boolean; seq?: number; data?: T }> {
282
- const { pushEnabled = true, transactionId } = opts;
283
- return this.submit<T>(
284
- "account_device",
285
- {
286
- seq: deviceSeq,
287
- push_token: pushToken,
288
- push_enabled: pushEnabled,
289
- },
290
- { transactionId },
291
- );
292
- }
293
-
294
- disablePushDevice<T = unknown>(
295
- deviceSeq: number,
296
- opts: { transactionId?: string } = {},
297
- ): Promise<{ ok: boolean; seq?: number; data?: T }> {
298
- return this.submit<T>(
299
- "account_device",
300
- {
301
- seq: deviceSeq,
302
- push_enabled: false,
303
- },
304
- { transactionId: opts.transactionId },
305
- );
306
- }
307
-
308
- readRequestBody<T = Record<string, unknown>>(
309
- body: ArrayBuffer | Uint8Array | string | T | null | undefined,
310
- contentType = "application/json",
311
- requireEncrypted = false,
312
- ): T {
313
- const lowered = contentType.toLowerCase();
314
- const isEncrypted = lowered.includes("application/octet-stream");
315
-
316
- if (requireEncrypted && !isEncrypted) {
317
- throw new Error(
318
- "Encrypted request required: Content-Type must be application/octet-stream",
319
- );
320
- }
321
-
322
- if (isEncrypted) {
323
- if (body == null) {
324
- throw new Error("Encrypted request body is empty");
325
- }
326
- if (body instanceof ArrayBuffer) {
327
- return this.decryptPacket<T>(body);
328
- }
329
- if (body instanceof Uint8Array) {
330
- const sliced = body.buffer.slice(
331
- body.byteOffset,
332
- body.byteOffset + body.byteLength,
333
- );
334
- return this.decryptPacket<T>(sliced);
335
- }
336
- throw new Error(
337
- "Encrypted request body must be ArrayBuffer or Uint8Array",
338
- );
339
- }
340
-
341
- if (body == null || body === "") return {} as T;
342
- if (typeof body === "string") return JSON.parse(body) as T;
343
- return body as T;
344
- }
345
-
346
- private async request<T>(
347
- method: string,
348
- path: string,
349
- body?: unknown,
350
- withAuth = true,
351
- extraHeaders: Record<string, string> = {},
352
- ): Promise<T> {
353
- const headers: Record<string, string> = {
354
- "Content-Type": "application/json",
355
- ...extraHeaders,
356
- };
357
- if (withAuth && this.token) {
358
- headers["Authorization"] = `Bearer ${this.token}`;
359
- }
360
-
361
- const res = await fetch(this.baseUrl + path, {
362
- method,
363
- headers,
364
- ...(body != null ? { body: JSON.stringify(body) } : {}),
365
- });
366
-
367
- const contentType = res.headers.get("Content-Type") ?? "";
368
-
369
- // 패킷 암호화 응답: application/octet-stream → 복호화
370
- if (contentType.includes("application/octet-stream")) {
371
- const buffer = await res.arrayBuffer();
372
- return this.decryptPacket<T>(buffer);
373
- }
374
-
375
- const data = await res.json();
376
- if (!data.ok) {
377
- const err = new Error(
378
- data.message ?? `EntityServer error (HTTP ${res.status})`,
379
- );
380
- (err as { status?: number }).status = res.status;
381
- throw err;
382
- }
383
- return data as T;
384
- }
385
-
386
- /**
387
- * XChaCha20-Poly1305 패킷 복호화
388
- * 포맷: [magic:magicLen][nonce:24][ciphertext+tag]
389
- * 키: sha256(access_token)
390
- */
391
- private decryptPacket<T>(buffer: ArrayBuffer): T {
392
- const key = sha256(new TextEncoder().encode(this.token));
393
- const data = new Uint8Array(buffer);
394
- const nonce = data.slice(this.magicLen, this.magicLen + 24);
395
- const ciphertext = data.slice(this.magicLen + 24);
396
- const cipher = xchacha20_poly1305(key, nonce);
397
- const plaintext = cipher.decrypt(ciphertext);
398
- return JSON.parse(new TextDecoder().decode(plaintext)) as T;
399
- }
400
- }
401
-
402
- function buildQuery(params: Record<string, unknown>): string {
403
- return Object.entries(params)
404
- .filter(([, v]) => v != null)
405
- .map(
406
- ([k, v]) =>
407
- `${encodeURIComponent(k === "orderBy" ? "order_by" : k)}=${encodeURIComponent(String(v))}`,
408
- )
409
- .join("&");
410
- }
411
-
412
- /** 싱글턴 인스턴스 (앱 전체 공유) */
413
- export const entityServer = new EntityServerClient();