@gencow/core 0.1.27 → 0.1.29

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 (130) hide show
  1. package/dist/auth-config.d.ts +92 -5
  2. package/dist/config.d.ts +107 -0
  3. package/dist/config.js +12 -0
  4. package/dist/context.d.ts +139 -0
  5. package/dist/context.js +3 -0
  6. package/dist/crud.d.ts +5 -5
  7. package/dist/crud.js +19 -35
  8. package/dist/document-types.d.ts +65 -0
  9. package/dist/document-types.js +15 -0
  10. package/dist/grounded-answer-types.d.ts +62 -0
  11. package/dist/grounded-answer-types.js +6 -0
  12. package/dist/http-action.d.ts +77 -0
  13. package/dist/http-action.js +41 -0
  14. package/dist/index.d.ts +30 -5
  15. package/dist/index.js +15 -2
  16. package/dist/platform-capacity-profile.d.ts +19 -0
  17. package/dist/platform-capacity-profile.js +94 -0
  18. package/dist/procedure.d.ts +58 -0
  19. package/dist/procedure.js +115 -0
  20. package/dist/rag-ingest-types.d.ts +39 -0
  21. package/dist/rag-ingest-types.js +1 -0
  22. package/dist/rag-operations-types.d.ts +81 -0
  23. package/dist/rag-operations-types.js +1 -0
  24. package/dist/rag-schema.d.ts +1466 -0
  25. package/dist/rag-schema.js +87 -0
  26. package/dist/reactive-mutation-types.d.ts +11 -0
  27. package/dist/reactive-mutation-types.js +1 -0
  28. package/dist/reactive-mutation.d.ts +51 -0
  29. package/dist/reactive-mutation.js +75 -0
  30. package/dist/reactive-query-types.d.ts +12 -0
  31. package/dist/reactive-query-types.js +1 -0
  32. package/dist/reactive-query.d.ts +14 -0
  33. package/dist/reactive-query.js +28 -0
  34. package/dist/reactive-realtime.d.ts +48 -0
  35. package/dist/reactive-realtime.js +236 -0
  36. package/dist/reactive.d.ts +29 -5
  37. package/dist/reactive.js +65 -0
  38. package/dist/rls-db.d.ts +9 -2
  39. package/dist/runtime-env-policy.d.ts +5 -0
  40. package/dist/runtime-env-policy.js +56 -0
  41. package/dist/search-types.d.ts +83 -0
  42. package/dist/search-types.js +1 -0
  43. package/dist/server.d.ts +1 -2
  44. package/dist/server.js +0 -1
  45. package/dist/storage-metering.d.ts +13 -0
  46. package/dist/storage-metering.js +18 -0
  47. package/dist/storage-shared.d.ts +36 -0
  48. package/dist/storage-shared.js +39 -0
  49. package/dist/storage.d.ts +5 -27
  50. package/dist/storage.js +30 -22
  51. package/dist/wake-app-result.d.ts +22 -0
  52. package/dist/wake-app-result.js +11 -0
  53. package/dist/workflow-types.d.ts +16 -2
  54. package/dist/workflow.d.ts +1 -1
  55. package/dist/workflow.js +136 -11
  56. package/dist/workflows-api.js +71 -3
  57. package/package.json +11 -7
  58. package/src/auth-config.ts +104 -3
  59. package/src/config.ts +119 -0
  60. package/src/context.ts +152 -0
  61. package/src/crud.ts +18 -35
  62. package/src/document-types.ts +102 -0
  63. package/src/grounded-answer-types.ts +78 -0
  64. package/src/http-action.ts +101 -0
  65. package/src/index.ts +142 -19
  66. package/src/platform-capacity-profile.ts +114 -0
  67. package/src/procedure.ts +283 -0
  68. package/src/rag-ingest-types.ts +52 -0
  69. package/src/rag-operations-types.ts +90 -0
  70. package/src/rag-schema.ts +94 -0
  71. package/src/reactive-mutation-types.ts +13 -0
  72. package/src/reactive-mutation.ts +115 -0
  73. package/src/reactive-query-types.ts +14 -0
  74. package/src/reactive-query.ts +48 -0
  75. package/src/reactive-realtime.ts +267 -0
  76. package/src/rls-db.ts +9 -4
  77. package/src/runtime-env-policy.ts +66 -0
  78. package/src/search-types.ts +91 -0
  79. package/src/server.ts +6 -2
  80. package/src/storage-metering.ts +35 -0
  81. package/src/storage-shared.ts +74 -0
  82. package/src/storage.ts +44 -53
  83. package/src/wake-app-result.ts +37 -0
  84. package/src/workflow-types.ts +16 -2
  85. package/src/workflow.ts +166 -12
  86. package/src/workflows-api.ts +82 -3
  87. package/src/__tests__/auth.test.ts +0 -118
  88. package/src/__tests__/crons.test.ts +0 -83
  89. package/src/__tests__/crud-codegen-integration.test.ts +0 -246
  90. package/src/__tests__/crud-owner-rls.test.ts +0 -387
  91. package/src/__tests__/crud.test.ts +0 -930
  92. package/src/__tests__/dist-exports.test.ts +0 -176
  93. package/src/__tests__/fixtures/basic/auth.ts +0 -32
  94. package/src/__tests__/fixtures/basic/drizzle.config.ts +0 -12
  95. package/src/__tests__/fixtures/basic/index.ts +0 -6
  96. package/src/__tests__/fixtures/basic/migrations/0000_last_warstar.sql +0 -75
  97. package/src/__tests__/fixtures/basic/migrations/meta/0000_snapshot.json +0 -497
  98. package/src/__tests__/fixtures/basic/migrations/meta/_journal.json +0 -13
  99. package/src/__tests__/fixtures/basic/schema.ts +0 -51
  100. package/src/__tests__/fixtures/basic/tasks.ts +0 -15
  101. package/src/__tests__/fixtures/common/auth-schema.ts +0 -67
  102. package/src/__tests__/helpers/basic-rls-fixture.ts +0 -135
  103. package/src/__tests__/helpers/pglite-migrations.ts +0 -32
  104. package/src/__tests__/helpers/pglite-rls-session.ts +0 -51
  105. package/src/__tests__/helpers/seed-like-fill.ts +0 -202
  106. package/src/__tests__/helpers/test-gencow-ctx-rls.ts +0 -50
  107. package/src/__tests__/httpaction.test.ts +0 -122
  108. package/src/__tests__/image-optimization.test.ts +0 -648
  109. package/src/__tests__/load.test.ts +0 -389
  110. package/src/__tests__/network-sim.test.ts +0 -319
  111. package/src/__tests__/reactive.test.ts +0 -479
  112. package/src/__tests__/retry.test.ts +0 -113
  113. package/src/__tests__/rls-crud-basic.test.ts +0 -317
  114. package/src/__tests__/rls-crud-no-owner-rls-pglite.test.ts +0 -117
  115. package/src/__tests__/rls-custom-mutation-handlers.test.ts +0 -142
  116. package/src/__tests__/rls-custom-query-handlers.test.ts +0 -128
  117. package/src/__tests__/rls-db-leased-connection.test.ts +0 -118
  118. package/src/__tests__/rls-session-and-policies.test.ts +0 -228
  119. package/src/__tests__/scheduler-durable-v2.test.ts +0 -288
  120. package/src/__tests__/scheduler-durable.test.ts +0 -173
  121. package/src/__tests__/scheduler-exec.test.ts +0 -328
  122. package/src/__tests__/scheduler.test.ts +0 -187
  123. package/src/__tests__/storage.test.ts +0 -334
  124. package/src/__tests__/tsconfig.json +0 -8
  125. package/src/__tests__/validator.test.ts +0 -323
  126. package/src/__tests__/workflow.test.ts +0 -606
  127. package/src/__tests__/ws-integration.test.ts +0 -309
  128. package/src/__tests__/ws-scale.test.ts +0 -241
  129. package/src/auth.ts +0 -155
  130. package/src/reactive.ts +0 -580
@@ -0,0 +1,90 @@
1
+ import type { SearchScope } from "./search-types.js";
2
+
3
+ export type RagOperationKind = "ingest" | "retrieve" | "answer" | "reindex" | "evaluate";
4
+ export type RagOperationMetricUnit = "ms" | "count" | "tokens" | "usd";
5
+ export type RagIndexHealth = "ready" | "empty" | "degraded" | "failed";
6
+
7
+ export type RagOperationMetric = {
8
+ id: string;
9
+ appId: string;
10
+ corpus: string;
11
+ visibility: SearchScope["visibility"];
12
+ ownerUserId: string | null;
13
+ operation: RagOperationKind;
14
+ jobId: string | null;
15
+ workflowId: string | null;
16
+ sourceId: string | null;
17
+ metricName: string;
18
+ metricValue: number;
19
+ unit: RagOperationMetricUnit | null;
20
+ metadata: Record<string, unknown>;
21
+ recordedAt: string;
22
+ };
23
+
24
+ export type RagOperationsSummary = {
25
+ corpus: string;
26
+ visibility: SearchScope["visibility"];
27
+ ownerUserId: string | null;
28
+ sourceCount: number;
29
+ sectionCount: number;
30
+ chunkCount: number;
31
+ jobCounts: Record<string, number>;
32
+ latestJob: {
33
+ id: string;
34
+ status: string;
35
+ stage: string;
36
+ updatedAt: string;
37
+ } | null;
38
+ recentMetricCounts: Record<string, number>;
39
+ averageLatencyMs: number | null;
40
+ indexHealth: RagIndexHealth;
41
+ };
42
+
43
+ export type RagEvaluationExpectedClaim = {
44
+ claim: string;
45
+ verdict: "supported" | "partial" | "unsupported";
46
+ };
47
+
48
+ export type RagEvaluationFixture = {
49
+ name: string;
50
+ scope: SearchScope;
51
+ query: string;
52
+ expectedSourceIds?: string[];
53
+ expectedCitationCountMin?: number;
54
+ expectedClaims?: RagEvaluationExpectedClaim[];
55
+ };
56
+
57
+ export type RagEvaluationFixtureResult = {
58
+ name: string;
59
+ ok: boolean;
60
+ failures: string[];
61
+ matchedSourceIds: string[];
62
+ citationCount: number;
63
+ claimStatuses: Array<{
64
+ claim: string;
65
+ expected: RagEvaluationExpectedClaim["verdict"];
66
+ actual: string | null;
67
+ }>;
68
+ };
69
+
70
+ export type RagEvaluationRunResult = {
71
+ ok: boolean;
72
+ total: number;
73
+ passed: number;
74
+ failed: number;
75
+ results: RagEvaluationFixtureResult[];
76
+ };
77
+
78
+ export type RagReindexMode = "source-changed" | "section-changed" | "corpus-policy-changed" | "full-rebuild";
79
+
80
+ export type RagReindexPlan = {
81
+ corpus: string;
82
+ visibility: SearchScope["visibility"];
83
+ ownerUserId: string | null;
84
+ mode: RagReindexMode;
85
+ reason: string;
86
+ sourceIds: string[];
87
+ sourceCount: number;
88
+ estimatedChunkCount: number;
89
+ requiresConfirmation: boolean;
90
+ };
@@ -0,0 +1,94 @@
1
+ import { bigint, boolean, doublePrecision, integer, jsonb, pgTable, text, timestamp, vector } from "drizzle-orm/pg-core";
2
+
3
+ function buildScopeColumns() {
4
+ return {
5
+ id: text("id").primaryKey(),
6
+ corpus: text("corpus").notNull(),
7
+ visibilityScope: text("visibility_scope").notNull(),
8
+ ownerUserId: text("owner_user_id"),
9
+ createdAt: timestamp("created_at").defaultNow().notNull(),
10
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
11
+ };
12
+ }
13
+
14
+ export const ragCorpora = pgTable("rag_corpora", {
15
+ ...buildScopeColumns(),
16
+ title: text("title").notNull(),
17
+ description: text("description"),
18
+ allowExternalProviders: boolean("allow_external_providers").default(false).notNull(),
19
+ retentionDays: integer("retention_days"),
20
+ metadata: jsonb("metadata").default({}).notNull(),
21
+ });
22
+
23
+ export const ragSources = pgTable("rag_sources", {
24
+ ...buildScopeColumns(),
25
+ storageId: text("storage_id").notNull(),
26
+ sourceKey: text("source_key").notNull(),
27
+ sourceTitle: text("source_title").notNull(),
28
+ mimeType: text("mime_type").notNull(),
29
+ byteSize: bigint("byte_size", { mode: "number" }).notNull(),
30
+ sourceChecksum: text("source_checksum").notNull(),
31
+ convertProvider: text("convert_provider").notNull(),
32
+ convertStatus: text("convert_status").notNull(),
33
+ markdown: text("markdown").notNull(),
34
+ text: text("text").notNull(),
35
+ warnings: jsonb("warnings").default([]).notNull(),
36
+ metadata: jsonb("metadata").default({}).notNull(),
37
+ });
38
+
39
+ export const ragSections = pgTable("rag_sections", {
40
+ ...buildScopeColumns(),
41
+ sourceId: text("source_id").notNull(),
42
+ sectionIndex: integer("section_index").notNull(),
43
+ sectionPath: jsonb("section_path").default([]).notNull(),
44
+ title: text("title"),
45
+ depth: integer("depth").notNull(),
46
+ pageStart: integer("page_start"),
47
+ pageEnd: integer("page_end"),
48
+ charStart: integer("char_start").notNull(),
49
+ charEnd: integer("char_end").notNull(),
50
+ });
51
+
52
+ export const ragChunks = pgTable("rag_chunks", {
53
+ ...buildScopeColumns(),
54
+ sourceId: text("source_id").notNull(),
55
+ sectionId: text("section_id"),
56
+ chunkIndex: integer("chunk_index").notNull(),
57
+ chunkText: text("chunk_text").notNull(),
58
+ lexicalText: text("lexical_text").notNull(),
59
+ embedding: vector("embedding", { dimensions: 1536 }),
60
+ pageStart: integer("page_start"),
61
+ pageEnd: integer("page_end"),
62
+ chunkChecksum: text("chunk_checksum").notNull(),
63
+ metadata: jsonb("metadata").default({}).notNull(),
64
+ });
65
+
66
+ export const ragIngestJobs = pgTable("rag_ingest_jobs", {
67
+ ...buildScopeColumns(),
68
+ workflowId: text("workflow_id").notNull(),
69
+ sourceId: text("source_id").notNull(),
70
+ status: text("status").notNull(),
71
+ stage: text("stage").notNull(),
72
+ providerTrace: jsonb("provider_trace").default({}).notNull(),
73
+ metrics: jsonb("metrics").default({}).notNull(),
74
+ error: text("error"),
75
+ startedAt: timestamp("started_at").defaultNow().notNull(),
76
+ completedAt: timestamp("completed_at"),
77
+ });
78
+
79
+ export const ragOperationMetrics = pgTable("rag_operation_metrics", {
80
+ id: text("id").primaryKey(),
81
+ appId: text("app_id").notNull(),
82
+ corpus: text("corpus").notNull(),
83
+ visibilityScope: text("visibility_scope").notNull(),
84
+ ownerUserId: text("owner_user_id"),
85
+ operation: text("operation").notNull(),
86
+ jobId: text("job_id"),
87
+ workflowId: text("workflow_id"),
88
+ sourceId: text("source_id"),
89
+ metricName: text("metric_name").notNull(),
90
+ metricValue: doublePrecision("metric_value").notNull(),
91
+ unit: text("unit"),
92
+ metadata: jsonb("metadata").default({}).notNull(),
93
+ recordedAt: timestamp("recorded_at").defaultNow().notNull(),
94
+ });
@@ -0,0 +1,13 @@
1
+ import type { InferArgs } from "./v.js";
2
+ import type { GencowCtx } from "./context.js";
3
+
4
+ export type MutationHandler<TArgs = any, TReturn = any> = (ctx: GencowCtx, args: TArgs) => Promise<TReturn>;
5
+
6
+ export interface MutationDef<TSchema = any, TReturn = any> {
7
+ handler: MutationHandler<InferArgs<TSchema>, TReturn>;
8
+ argsSchema?: TSchema;
9
+ /** true = 인증 없이 접근 가능, false(기본) = auth 필수 (Secure by Default) */
10
+ isPublic: boolean;
11
+ _args?: InferArgs<TSchema>;
12
+ _return?: TReturn;
13
+ }
@@ -0,0 +1,115 @@
1
+ import { type InferArgs } from "./v.js";
2
+ import type { MutationDef, MutationHandler } from "./reactive-mutation-types.js";
3
+
4
+ // ─── mutation registry (globalThis) ─────────────────────
5
+ // globalThis shared singleton — dual-bundle safe (see analysis-dual-module-registry.md).
6
+
7
+ declare global {
8
+ var __gencow_mutationRegistry: (MutationDef<any, any> & { name: string })[];
9
+ }
10
+
11
+ if (!globalThis.__gencow_mutationRegistry) globalThis.__gencow_mutationRegistry = [];
12
+
13
+ export const mutationRegistry = globalThis.__gencow_mutationRegistry;
14
+
15
+ let mutationCounter = 0;
16
+
17
+ /**
18
+ * mutation — 데이터 변경 함수를 선언적으로 등록합니다.
19
+ *
20
+ * 3가지 호출 방식 지원 (query와 동일한 패턴 우선):
21
+ *
22
+ * @example
23
+ * ```typescript
24
+ * // ✅ 권장: query와 동일한 (name, def) 패턴
25
+ * mutation("tasks.create", {
26
+ * args: { title: v.string() },
27
+ * handler: async (ctx, args) => { ... },
28
+ * });
29
+ *
30
+ * // ✅ 객체 스타일 (하위 호환)
31
+ * mutation({
32
+ * name: "tasks.create",
33
+ * handler: async (ctx) => { ... },
34
+ * });
35
+ *
36
+ * // ⚠️ Legacy 배열 스타일 (비권장)
37
+ * mutation(["tasks.list"], handler, "tasks.create");
38
+ * ```
39
+ *
40
+ * @note invalidates 필드는 deprecated — 전달해도 무시됩니다.
41
+ * 리얼타임 UI 갱신에는 ctx.realtime.emit() 또는 ctx.realtime.refresh()를 사용하세요.
42
+ */
43
+ export function mutation<TSchema = any, TReturn = any>(
44
+ nameOrInvalidatesOrDef:
45
+ | string
46
+ | string[]
47
+ | {
48
+ name?: string;
49
+ args?: TSchema;
50
+ public?: boolean;
51
+ invalidates?: string[];
52
+ handler: MutationHandler<InferArgs<TSchema>, TReturn>;
53
+ },
54
+ handlerOrDef?:
55
+ | MutationHandler<InferArgs<TSchema>, TReturn>
56
+ | {
57
+ invalidates?: string[];
58
+ args?: TSchema;
59
+ public?: boolean;
60
+ handler: MutationHandler<InferArgs<TSchema>, TReturn>;
61
+ },
62
+ name?: string,
63
+ ): MutationDef<TSchema, TReturn> {
64
+ let argsSchema: TSchema | undefined;
65
+ let actualHandler: MutationHandler<InferArgs<TSchema>, TReturn>;
66
+ let mutName: string;
67
+ let isPublic = false;
68
+
69
+ if (typeof nameOrInvalidatesOrDef === "string") {
70
+ // New primary style: mutation("name", { args?, public?, handler })
71
+ mutName = nameOrInvalidatesOrDef;
72
+ const def = handlerOrDef as {
73
+ args?: TSchema;
74
+ public?: boolean;
75
+ handler: MutationHandler<InferArgs<TSchema>, TReturn>;
76
+ };
77
+ actualHandler = def.handler;
78
+ argsSchema = def.args;
79
+ isPublic = def.public === true;
80
+ } else if (Array.isArray(nameOrInvalidatesOrDef)) {
81
+ // Legacy style: mutation([...], handler, "name") — invalidates ignored
82
+ actualHandler = handlerOrDef as MutationHandler<InferArgs<TSchema>, TReturn>;
83
+ mutName = name || `mutation_${++mutationCounter}`;
84
+ } else {
85
+ // Object style: mutation({ name?, args?, public?, handler })
86
+ actualHandler = nameOrInvalidatesOrDef.handler;
87
+ argsSchema = nameOrInvalidatesOrDef.args;
88
+ isPublic = nameOrInvalidatesOrDef.public === true;
89
+ mutName =
90
+ nameOrInvalidatesOrDef.name ||
91
+ (typeof name === "string" ? name : "") ||
92
+ `mutation_${++mutationCounter}`;
93
+ }
94
+
95
+ // 이름 미지정 시 경고 — 디버깅 지원
96
+ if (mutName.startsWith("mutation_")) {
97
+ console.warn(
98
+ `[gencow] mutation registered without explicit name → "${mutName}". ` +
99
+ `Use mutation("myMutation", { handler }) for better debugging.`,
100
+ );
101
+ }
102
+
103
+ const def: MutationDef<TSchema, TReturn> & { name: string } = {
104
+ name: mutName,
105
+ handler: actualHandler,
106
+ argsSchema,
107
+ isPublic,
108
+ };
109
+ mutationRegistry.push(def);
110
+ return def;
111
+ }
112
+
113
+ export function getRegisteredMutations(): (MutationDef & { name: string })[] {
114
+ return [...mutationRegistry];
115
+ }
@@ -0,0 +1,14 @@
1
+ import type { InferArgs } from "./v.js";
2
+ import type { GencowCtx } from "./context.js";
3
+
4
+ export type QueryHandler<TArgs = any, TReturn = any> = (ctx: GencowCtx, args: TArgs) => Promise<TReturn>;
5
+
6
+ export interface QueryDef<TSchema = any, TReturn = any> {
7
+ key: string;
8
+ handler: QueryHandler<InferArgs<TSchema>, TReturn>;
9
+ argsSchema?: TSchema;
10
+ /** true = 인증 없이 접근 가능, false(기본) = auth 필수 (Secure by Default) */
11
+ isPublic: boolean;
12
+ _args?: InferArgs<TSchema>;
13
+ _return?: TReturn;
14
+ }
@@ -0,0 +1,48 @@
1
+ import { type InferArgs } from "./v.js";
2
+ import type { QueryDef, QueryHandler } from "./reactive-query-types.js";
3
+
4
+ // ─── query registry (globalThis) ─────────────────────────
5
+ // globalThis shared singleton — dual-bundle safe (see analysis-dual-module-registry.md).
6
+
7
+ declare global {
8
+ var __gencow_queryRegistry: Map<string, QueryDef<any, any>>;
9
+ }
10
+
11
+ if (!globalThis.__gencow_queryRegistry) globalThis.__gencow_queryRegistry = new Map();
12
+
13
+ export const queryRegistry = globalThis.__gencow_queryRegistry;
14
+
15
+ export function query<TSchema = any, TReturn = any>(
16
+ key: string,
17
+ handlerOrDef:
18
+ | QueryHandler<InferArgs<TSchema>, TReturn>
19
+ | { args?: TSchema; public?: boolean; handler: QueryHandler<InferArgs<TSchema>, TReturn> },
20
+ ): QueryDef<TSchema, TReturn> {
21
+ let handler: QueryHandler<InferArgs<TSchema>, TReturn>;
22
+ let argsSchema: TSchema | undefined;
23
+ let isPublic = false;
24
+
25
+ if (typeof handlerOrDef === "function") {
26
+ handler = handlerOrDef;
27
+ } else {
28
+ handler = handlerOrDef.handler;
29
+ argsSchema = handlerOrDef.args;
30
+ isPublic = handlerOrDef.public === true;
31
+ }
32
+
33
+ const def: QueryDef<TSchema, TReturn> = { key, handler, argsSchema, isPublic };
34
+ queryRegistry.set(key, def);
35
+ return def;
36
+ }
37
+
38
+ export function getQueryHandler(key: string): QueryHandler | undefined {
39
+ return queryRegistry.get(key)?.handler;
40
+ }
41
+
42
+ export function getQueryDef(key: string): QueryDef | undefined {
43
+ return queryRegistry.get(key);
44
+ }
45
+
46
+ export function getRegisteredQueries(): string[] {
47
+ return Array.from(queryRegistry.keys());
48
+ }
@@ -0,0 +1,267 @@
1
+ import type { WSContext } from "hono/ws";
2
+ import type { GencowCtx, RealtimeCtx, RealtimeNotifyEvent } from "./context.js";
3
+ import { queryRegistry } from "./reactive-query.js";
4
+ import type { QueryDef } from "./reactive-query-types.js";
5
+
6
+ // ─── WS / subscription + subscription-key helpers (globalThis) ─
7
+ //
8
+ // globalThis 기반 — 서버 번들(인라인)과 node_modules/@gencow/core 양쪽에서
9
+ // 동일한 레지스트리 인스턴스를 공유. Dual-Module Registry 버그 방지.
10
+ // See: docs/analysis/analysis-dual-module-registry.md
11
+
12
+ declare global {
13
+ var __gencow_subscribers: Map<string, Set<WSContext>>;
14
+ var __gencow_connectedClients: Set<WSContext>;
15
+ }
16
+
17
+ if (!globalThis.__gencow_subscribers) globalThis.__gencow_subscribers = new Map();
18
+ if (!globalThis.__gencow_connectedClients) globalThis.__gencow_connectedClients = new Set();
19
+
20
+ export const subscribers = globalThis.__gencow_subscribers;
21
+
22
+ /**
23
+ * Every WebSocket client that ever establishes a connection is tracked here.
24
+ * This allows broadcasting invalidation events to clients that never subscribed
25
+ * to a specific query (e.g. the admin dashboard's raw WebSocket connection).
26
+ */
27
+ export const connectedClients = globalThis.__gencow_connectedClients;
28
+
29
+ const SUBSCRIPTION_KEY_SEPARATOR = "::";
30
+
31
+ function normalizeForStableJson(value: unknown): unknown {
32
+ if (value === undefined) return undefined;
33
+ if (value === null) return null;
34
+ if (value instanceof Date) return value.toISOString();
35
+ if (Array.isArray(value)) return value.map((item) => normalizeForStableJson(item));
36
+ if (typeof value === "object") {
37
+ const source = value as Record<string, unknown>;
38
+ const sorted: Record<string, unknown> = {};
39
+ for (const key of Object.keys(source).sort()) {
40
+ const normalized = normalizeForStableJson(source[key]);
41
+ if (normalized !== undefined) sorted[key] = normalized;
42
+ }
43
+ return sorted;
44
+ }
45
+ return value;
46
+ }
47
+
48
+ function isEmptyPlainObject(value: unknown): boolean {
49
+ return !!value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === 0;
50
+ }
51
+
52
+ /** Stable subscription key for WebSocket / invalidate fanout (also used by crud for query keys). */
53
+ export function buildQuerySubscriptionKey(queryKey: string, args?: unknown): string {
54
+ const normalized = normalizeForStableJson(args);
55
+ if (normalized === undefined || isEmptyPlainObject(normalized)) return queryKey;
56
+ return `${queryKey}${SUBSCRIPTION_KEY_SEPARATOR}${JSON.stringify(normalized)}`;
57
+ }
58
+
59
+ export function subscriptionKeyMatchesQueryKey(subscriptionKey: string, queryKey: string): boolean {
60
+ return subscriptionKey === queryKey || subscriptionKey.startsWith(`${queryKey}${SUBSCRIPTION_KEY_SEPARATOR}`);
61
+ }
62
+
63
+ export function subscribe(queryKey: string, ws: WSContext) {
64
+ connectedClients.add(ws);
65
+ if (!subscribers.has(queryKey)) {
66
+ subscribers.set(queryKey, new Set());
67
+ }
68
+ subscribers.get(queryKey)!.add(ws);
69
+ }
70
+
71
+ export function unsubscribe(ws: WSContext) {
72
+ connectedClients.delete(ws);
73
+ for (const clients of subscribers.values()) {
74
+ clients.delete(ws);
75
+ }
76
+ }
77
+
78
+ /** Register a raw WS connection without any query subscription (e.g. admin dashboard) */
79
+ export function registerClient(ws: WSContext) {
80
+ connectedClients.add(ws);
81
+ }
82
+
83
+ export function deregisterClient(ws: WSContext) {
84
+ connectedClients.delete(ws);
85
+ for (const clients of subscribers.values()) {
86
+ clients.delete(ws);
87
+ }
88
+ }
89
+
90
+ function sendInvalidateToLocalSubscribers(queryKeys: string[]): void {
91
+ const targets = new Map<WSContext, Set<string>>();
92
+ for (const queryKey of queryKeys) {
93
+ for (const [subscriptionKey, clients] of subscribers) {
94
+ if (!subscriptionKeyMatchesQueryKey(subscriptionKey, queryKey)) continue;
95
+ for (const ws of clients) {
96
+ if (!targets.has(ws)) targets.set(ws, new Set());
97
+ targets.get(ws)!.add(subscriptionKey);
98
+ }
99
+ }
100
+ }
101
+
102
+ for (const [ws, keys] of targets) {
103
+ try {
104
+ ws.send(JSON.stringify({ type: "invalidate", queries: [...keys] }));
105
+ } catch {
106
+ deregisterClient(ws);
107
+ }
108
+ }
109
+ }
110
+
111
+ /**
112
+ * mutation 실행 시점에 생성되는 RealtimeCtx.
113
+ * emit(): 데이터를 직접 push (초고빈도 mutation용).
114
+ * refresh(): queryKey를 pending 큐에 추가, mutation 완료 후 서버가 query re-run하여 push.
115
+ *
116
+ * 💡 Batching: 같은 queryKey에 대한 emit이 50ms 내에 여러 번 호출되면
117
+ * 마지막 데이터만 push하여 불필요한 전송을 방지합니다.
118
+ *
119
+ * ⚠️ 매 mutation 호출마다 새로 생성해야 합니다 (debounce timer가 mutation scope에 격리).
120
+ *
121
+ * @param options.httpCallback BaaS 모드: Platform WS Gateway에 HTTP로 emit 전달.
122
+ * 설정되면 WS 직접 push 대신 이 콜백을 호출.
123
+ * 로컬 dev에서는 미설정 → 기존 WS 직접 push 유지.
124
+ * @param options.queryMap query 레지스트리 — refresh()에서 query handler를 찾아 re-run.
125
+ * @param options.buildCtxForRefresh refresh 시 query handler에 전달할 ctx 생성 함수.
126
+ */
127
+ export function buildRealtimeCtx(options?: {
128
+ httpCallback?: (event: RealtimeNotifyEvent) => void;
129
+ queryMap?: Map<string, QueryDef<any, any>>;
130
+ buildCtxForRefresh?: () => GencowCtx;
131
+ }): RealtimeCtx & { _hasEmitted: boolean; _pendingRefresh: string[]; _flushRefresh: () => Promise<void> } {
132
+ const pendingEmits = new Map<string, { data: unknown; timer: ReturnType<typeof setTimeout> }>();
133
+ const _pendingRefresh: string[] = [];
134
+ let _hasEmitted = false;
135
+
136
+ return {
137
+ emit(queryKey: string, data: unknown) {
138
+ _hasEmitted = true;
139
+ // 기존 pending timer가 있으면 취소 (debounce)
140
+ const existing = pendingEmits.get(queryKey);
141
+ if (existing) clearTimeout(existing.timer);
142
+
143
+ const timer = setTimeout(() => {
144
+ pendingEmits.delete(queryKey);
145
+
146
+ // BaaS 모드: Platform WS Gateway에 HTTP callback
147
+ if (options?.httpCallback) {
148
+ options.httpCallback({ type: "emit", queryKey, data });
149
+ return;
150
+ }
151
+
152
+ // 로컬 dev: WS 직접 push (기존 동작)
153
+ const clients = subscribers.get(queryKey);
154
+ if (!clients || clients.size === 0) return;
155
+
156
+ const message = JSON.stringify({
157
+ type: "query:updated",
158
+ query: queryKey,
159
+ data,
160
+ });
161
+ for (const ws of clients) {
162
+ try {
163
+ ws.send(message);
164
+ } catch {
165
+ clients.delete(ws);
166
+ }
167
+ }
168
+ }, 50); // 50ms batch window
169
+
170
+ pendingEmits.set(queryKey, { data, timer });
171
+ },
172
+
173
+ invalidate(queryKey: string | string[]) {
174
+ _hasEmitted = true;
175
+ const queryKeys = Array.isArray(queryKey) ? [...new Set(queryKey)] : [queryKey];
176
+ if (options?.httpCallback) {
177
+ options.httpCallback({ type: "invalidate", queryKeys });
178
+ return;
179
+ }
180
+ sendInvalidateToLocalSubscribers(queryKeys);
181
+ },
182
+
183
+ refresh(queryKey: string) {
184
+ _hasEmitted = true; // 경고 억제
185
+ if (!_pendingRefresh.includes(queryKey)) {
186
+ _pendingRefresh.push(queryKey);
187
+ }
188
+ },
189
+
190
+ get _hasEmitted() {
191
+ return _hasEmitted;
192
+ },
193
+ get _pendingRefresh() {
194
+ return [..._pendingRefresh];
195
+ },
196
+
197
+ async _flushRefresh() {
198
+ if (_pendingRefresh.length === 0) return;
199
+
200
+ // queryMap이 없으면 refresh 동작 불가 (로그 경고)
201
+ const qMap = options?.queryMap ?? queryRegistry;
202
+
203
+ for (const key of _pendingRefresh) {
204
+ const queryDef = qMap.get(key);
205
+ if (!queryDef) {
206
+ console.warn(`[gencow] refresh("${key}"): query not found in registry. Skipping.`);
207
+ continue;
208
+ }
209
+ try {
210
+ // refresh용 ctx 생성 (mutation ctx와 동일한 DB/auth 스코프)
211
+ if (!options?.buildCtxForRefresh) {
212
+ console.warn(
213
+ `[gencow] ⚠️ refresh("${key}"): buildCtxForRefresh not provided. ` +
214
+ `Query handler will receive an empty ctx — ctx.db will be undefined. ` +
215
+ `This is a framework configuration error. ` +
216
+ `💡 Ensure buildRealtimeCtx() receives a buildCtxForRefresh callback.`,
217
+ );
218
+ }
219
+ const refreshCtx = options?.buildCtxForRefresh?.() ?? ({} as GencowCtx);
220
+ const result = await queryDef.handler(refreshCtx, {});
221
+
222
+ // emit과 동일한 경로로 push
223
+ if (options?.httpCallback) {
224
+ options.httpCallback({ type: "emit", queryKey: key, data: result });
225
+ } else {
226
+ const clients = subscribers.get(key);
227
+ if (clients && clients.size > 0) {
228
+ const message = JSON.stringify({
229
+ type: "query:updated",
230
+ query: key,
231
+ data: result,
232
+ });
233
+ for (const ws of clients) {
234
+ try {
235
+ ws.send(message);
236
+ } catch {
237
+ clients.delete(ws);
238
+ }
239
+ }
240
+ }
241
+ }
242
+ } catch (e) {
243
+ console.warn(`[gencow] refresh("${key}") failed:`, e instanceof Error ? e.message : e);
244
+ }
245
+ }
246
+ _pendingRefresh.length = 0;
247
+ },
248
+ };
249
+ }
250
+
251
+ export function handleWsMessage(ws: WSContext, raw: string | ArrayBuffer) {
252
+ try {
253
+ const msg = typeof raw === "string" ? JSON.parse(raw) : JSON.parse(raw.toString());
254
+
255
+ if (msg.type === "subscribe" && msg.query) {
256
+ subscribe(msg.query, ws);
257
+ ws.send(JSON.stringify({ type: "subscribed", query: msg.query }));
258
+ }
259
+
260
+ if (msg.type === "unsubscribe" && msg.query) {
261
+ const clients = subscribers.get(msg.query);
262
+ if (clients) clients.delete(ws);
263
+ }
264
+ } catch {
265
+ // ignore malformed messages
266
+ }
267
+ }
package/src/rls-db.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { sql } from "drizzle-orm";
3
- import type { PgAsyncDatabase } from "drizzle-orm/pg-core";
4
3
 
5
4
  /**
6
5
  * RLS DB wrapper — execution paths for `withRlsConnection`:
@@ -33,6 +32,12 @@ const gucNameRe = /^app\.[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)*$/;
33
32
 
34
33
  const RESERVED_VARS_KEYS = new Set(["app.current_user_id", "app.current_user_role", "app.tenant_id"]);
35
34
 
35
+ type RlsDrizzleDatabaseLike = {
36
+ session: unknown;
37
+ _: { session?: unknown };
38
+ transaction: (callback: (tx: unknown) => unknown | Promise<unknown>, ...rest: unknown[]) => Promise<unknown>;
39
+ };
40
+
36
41
  function assertSafeGucName(key: string): void {
37
42
  if (!gucNameRe.test(key)) {
38
43
  throw new Error(
@@ -244,10 +249,10 @@ function wrapSession(session: any, rls: RlsSessionContext, reuseOuterConnection:
244
249
  *
245
250
  * `db.transaction()` still injects the same variables at the start of the callback transaction.
246
251
  */
247
- export function createRlsDb(
248
- db: PgAsyncDatabase<any, any, any, any>,
252
+ export function createRlsDb<TDb extends RlsDrizzleDatabaseLike>(
253
+ db: TDb,
249
254
  rls: RlsSessionContext,
250
- ): PgAsyncDatabase<any, any, any, any> {
255
+ ): TDb {
251
256
  const reuseOuterConnection = isDrizzleTransactionDb(db);
252
257
  const baseSession = (db as unknown as { session: any }).session;
253
258
  const wrappedSession = wrapSession(baseSession, rls, reuseOuterConnection);