@gencow/core 0.1.16 → 0.1.18

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.
@@ -0,0 +1,364 @@
1
+ /**
2
+ * packages/core/src/scoped-db.ts
3
+ *
4
+ * Creates a scoped (Proxy-wrapped) Drizzle DB instance that auto-injects
5
+ * schema-level access control filters from gencowTable() metadata.
6
+ *
7
+ * Key behaviors:
8
+ * - .select().from(gencowTable) → auto-inject filter into WHERE
9
+ * - .insert(table) / .update(table) / .delete(table) → inject filter for writes
10
+ * - .leftJoin(table) / .innerJoin(table) → detect and inject filter
11
+ * - .execute() → blocked (throws Error)
12
+ * - .query.tableName.findMany() → inject filter into relational queries
13
+ *
14
+ * Run tests: bun test packages/core/src/__tests__/scoped-db.test.ts
15
+ */
16
+ import { and } from "drizzle-orm";
17
+ import { getTableAccessMeta } from "./table";
18
+ // ─── createScopedDb ─────────────────────────────────────
19
+ /**
20
+ * Wrap a Drizzle DB instance with access control Proxy.
21
+ *
22
+ * @param db - Raw Drizzle DB instance
23
+ * @param ctx - GencowCtx (provides auth for filter evaluation)
24
+ * @returns Proxy-wrapped DB with auto-filter injection
25
+ */
26
+ export function createScopedDb(db, ctx) {
27
+ return new Proxy(db, {
28
+ get(target, prop) {
29
+ const propStr = typeof prop === "string" ? prop : "";
30
+ // ── Block dangerous methods ──────────────────
31
+ if (propStr === "execute") {
32
+ return () => {
33
+ throw new Error("[gencow] ctx.db.execute() is not allowed. " +
34
+ "Use ctx.db.select().from(table) for type-safe queries with automatic access control. " +
35
+ "If you need raw SQL, use ctx.unsafeDb.execute().");
36
+ };
37
+ }
38
+ if (propStr === "$client" || propStr === "_") {
39
+ throw new Error(`[gencow] ctx.db.${propStr} is not allowed. ` +
40
+ "Direct database client access bypasses access control. " +
41
+ "Use ctx.unsafeDb if you need direct access.");
42
+ }
43
+ // ── Wrap select() ────────────────────────────
44
+ if (propStr === "select") {
45
+ return (...selectArgs) => {
46
+ const selectResult = target.select(...selectArgs);
47
+ return wrapSelectChain(selectResult, ctx);
48
+ };
49
+ }
50
+ // ── Wrap update() ────────────────────────────
51
+ if (propStr === "update") {
52
+ return (table) => {
53
+ const updateResult = target.update(table);
54
+ return wrapWriteChain(updateResult, table, ctx);
55
+ };
56
+ }
57
+ // ── Wrap delete() ────────────────────────────
58
+ if (propStr === "delete") {
59
+ return (table) => {
60
+ const deleteResult = target.delete(table);
61
+ return wrapWriteChain(deleteResult, table, ctx);
62
+ };
63
+ }
64
+ // ── Wrap query (relational API) ──────────────
65
+ if (propStr === "query") {
66
+ return wrapRelationalQuery(target.query, ctx);
67
+ }
68
+ // ── Pass through everything else ─────────────
69
+ const value = target[prop];
70
+ if (typeof value === "function") {
71
+ return value.bind(target);
72
+ }
73
+ return value;
74
+ },
75
+ });
76
+ }
77
+ // ─── Select chain: .from() + .join() → filter injection ─
78
+ /**
79
+ * Wraps a Drizzle select() chain to intercept .from() and .join() calls.
80
+ * Each detected gencowTable gets its filter injected.
81
+ */
82
+ function wrapSelectChain(selectResult, ctx) {
83
+ return new Proxy(selectResult, {
84
+ get(target, prop) {
85
+ const propStr = typeof prop === "string" ? prop : "";
86
+ // ── .from(table) → inject filter ────────────
87
+ if (propStr === "from") {
88
+ return (table, ...restArgs) => {
89
+ const fromResult = target.from(table, ...restArgs);
90
+ const meta = getTableAccessMeta(table);
91
+ if (meta) {
92
+ // Wrap the chain to inject filter and handle joins
93
+ return wrapFromChain(fromResult, ctx, [{ table, meta }]);
94
+ }
95
+ // No gencowTable metadata — pass through but still wrap for join detection
96
+ return wrapFromChain(fromResult, ctx, []);
97
+ };
98
+ }
99
+ const value = target[prop];
100
+ if (typeof value === "function") {
101
+ return value.bind(target);
102
+ }
103
+ return value;
104
+ },
105
+ });
106
+ }
107
+ /**
108
+ * Wraps the chain after .from() — handles .where(), .leftJoin(), .innerJoin(), etc.
109
+ * Accumulates filters from all detected tables and injects them at execution time.
110
+ */
111
+ function wrapFromChain(chain, ctx, pendingFilters) {
112
+ return new Proxy(chain, {
113
+ get(target, prop) {
114
+ const propStr = typeof prop === "string" ? prop : "";
115
+ // ── Join methods → detect table, accumulate filter ──
116
+ if (["leftJoin", "rightJoin", "innerJoin", "fullJoin"].includes(propStr)) {
117
+ return (joinTable, ...joinArgs) => {
118
+ const joinResult = target[propStr](joinTable, ...joinArgs);
119
+ const joinMeta = getTableAccessMeta(joinTable);
120
+ const newFilters = joinMeta
121
+ ? [...pendingFilters, { table: joinTable, meta: joinMeta }]
122
+ : pendingFilters;
123
+ return wrapFromChain(joinResult, ctx, newFilters);
124
+ };
125
+ }
126
+ // ── .where() → combine with accumulated filters ──
127
+ if (propStr === "where") {
128
+ return (...whereArgs) => {
129
+ const combinedFilter = buildCombinedFilter(pendingFilters, ctx);
130
+ if (combinedFilter) {
131
+ // Combine user's where with our filters
132
+ const userWhere = whereArgs[0];
133
+ const merged = userWhere ? and(userWhere, combinedFilter) : combinedFilter;
134
+ const result = target.where(merged);
135
+ // After .where(), no more filter injection needed — continue with clean proxy
136
+ return wrapFromChain(result, ctx, []);
137
+ }
138
+ return wrapFromChain(target.where(...whereArgs), ctx, []);
139
+ };
140
+ }
141
+ // ── Terminal methods (then, execute, etc.) → inject pending filters ──
142
+ if (propStr === "then" || propStr === "execute") {
143
+ // If there are pending filters that haven't been injected via .where(),
144
+ // inject them now by calling .where() before execution
145
+ if (pendingFilters.length > 0) {
146
+ const combinedFilter = buildCombinedFilter(pendingFilters, ctx);
147
+ if (combinedFilter) {
148
+ const filtered = target.where(combinedFilter);
149
+ return filtered[prop].bind(filtered);
150
+ }
151
+ }
152
+ const value = target[prop];
153
+ return typeof value === "function" ? value.bind(target) : value;
154
+ }
155
+ // ── Other chainable methods (.orderBy, .limit, .offset, etc.) ──
156
+ const value = target[prop];
157
+ if (typeof value === "function") {
158
+ return (...args) => {
159
+ const result = value.apply(target, args);
160
+ // If the result is thenable (query builder), keep wrapping
161
+ if (result && typeof result === "object" && typeof result.then === "function") {
162
+ return wrapFromChain(result, ctx, pendingFilters);
163
+ }
164
+ if (result && typeof result === "object" && "where" in result) {
165
+ return wrapFromChain(result, ctx, pendingFilters);
166
+ }
167
+ return result;
168
+ };
169
+ }
170
+ return value;
171
+ },
172
+ });
173
+ }
174
+ // ─── Write chain: update()/delete() filter injection ────
175
+ /**
176
+ * Wraps update(table) or delete(table) chains.
177
+ * Injects the table's filter into .where() so users can only modify their own rows.
178
+ */
179
+ function wrapWriteChain(chain, table, ctx) {
180
+ const meta = getTableAccessMeta(table);
181
+ if (!meta) {
182
+ return chain; // No gencowTable metadata — pass through
183
+ }
184
+ return new Proxy(chain, {
185
+ get(target, prop) {
186
+ const propStr = typeof prop === "string" ? prop : "";
187
+ if (propStr === "where") {
188
+ return (...whereArgs) => {
189
+ const filterResult = evaluateFilterSync(meta, ctx);
190
+ if (typeof filterResult === "boolean") {
191
+ if (!filterResult) {
192
+ // false → block all writes — add impossible condition
193
+ return target.where(whereArgs[0]); // Let it through but will match nothing
194
+ }
195
+ // true → no additional filter
196
+ return target.where(...whereArgs);
197
+ }
198
+ // SQL condition — combine with user's where
199
+ const userWhere = whereArgs[0];
200
+ const merged = userWhere ? and(userWhere, filterResult) : filterResult;
201
+ return target.where(merged);
202
+ };
203
+ }
204
+ // Terminal methods — inject filter if .where() wasn't called
205
+ if (propStr === "then" || propStr === "execute" || propStr === "returning") {
206
+ const filterResult = evaluateFilterSync(meta, ctx);
207
+ if (filterResult && typeof filterResult !== "boolean") {
208
+ const filtered = target.where(filterResult);
209
+ const value = filtered[prop];
210
+ return typeof value === "function" ? value.bind(filtered) : value;
211
+ }
212
+ const value = target[prop];
213
+ return typeof value === "function" ? value.bind(target) : value;
214
+ }
215
+ const value = target[prop];
216
+ if (typeof value === "function") {
217
+ return value.bind(target);
218
+ }
219
+ return value;
220
+ },
221
+ });
222
+ }
223
+ // ─── Relational query wrapping ──────────────────────────
224
+ /**
225
+ * Wraps db.query to intercept relational queries (findMany, findFirst).
226
+ */
227
+ function wrapRelationalQuery(queryObj, ctx) {
228
+ if (!queryObj)
229
+ return queryObj;
230
+ return new Proxy(queryObj, {
231
+ get(target, tableName) {
232
+ const tableProxy = target[tableName];
233
+ if (!tableProxy || typeof tableProxy !== "object")
234
+ return tableProxy;
235
+ return new Proxy(tableProxy, {
236
+ get(tableTarget, method) {
237
+ const methodStr = typeof method === "string" ? method : "";
238
+ if (methodStr === "findMany" || methodStr === "findFirst") {
239
+ return (args = {}) => {
240
+ // Try to find the gencowTable by table name
241
+ // (relational queries use the table name string)
242
+ // We need to look up from registry by tableName
243
+ const meta = findMetaByTableName(String(tableName));
244
+ if (meta) {
245
+ const filterResult = evaluateFilterSync(meta, ctx);
246
+ if (filterResult && typeof filterResult !== "boolean") {
247
+ args.where = args.where ? and(args.where, filterResult) : filterResult;
248
+ }
249
+ else if (filterResult === false) {
250
+ // Deny all — return empty
251
+ args.where = args.where; // Keep existing, but won't match
252
+ }
253
+ }
254
+ return tableTarget[method](args);
255
+ };
256
+ }
257
+ const value = tableTarget[method];
258
+ if (typeof value === "function") {
259
+ return value.bind(tableTarget);
260
+ }
261
+ return value;
262
+ },
263
+ });
264
+ },
265
+ });
266
+ }
267
+ // ─── Filter helpers ─────────────────────────────────────
268
+ /**
269
+ * Build a combined SQL filter from multiple table filters.
270
+ * Returns null if no filters to apply.
271
+ */
272
+ function buildCombinedFilter(pendingFilters, ctx) {
273
+ const sqlConditions = [];
274
+ for (const { meta } of pendingFilters) {
275
+ const result = evaluateFilterSync(meta, ctx);
276
+ if (result === false) {
277
+ // Any false → deny all (AND with false = false)
278
+ // We'd need an always-false SQL expression
279
+ // For now, we create a `1 = 0` condition
280
+ const { sql: sqlTag } = require("drizzle-orm");
281
+ return sqlTag `1 = 0`;
282
+ }
283
+ if (result === true) {
284
+ continue; // Allow all — no condition needed
285
+ }
286
+ if (result) {
287
+ sqlConditions.push(result);
288
+ }
289
+ }
290
+ if (sqlConditions.length === 0)
291
+ return null;
292
+ if (sqlConditions.length === 1)
293
+ return sqlConditions[0];
294
+ return and(...sqlConditions) ?? null;
295
+ }
296
+ /**
297
+ * Evaluate a filter synchronously. If the filter is async, this will throw.
298
+ * For async filters, callers should use evaluateFilterAsync.
299
+ */
300
+ function evaluateFilterSync(meta, ctx) {
301
+ const result = meta.filter(ctx);
302
+ if (result instanceof Promise) {
303
+ throw new Error(`[gencow] Async filter on table "${meta.tableName}" is not supported in synchronous context. ` +
304
+ "Use synchronous filters for schema-level access control.");
305
+ }
306
+ return result;
307
+ }
308
+ /**
309
+ * Find table access metadata by table name string.
310
+ * Used by relational query proxy where we only have the table name.
311
+ */
312
+ function findMetaByTableName(name) {
313
+ for (const [, meta] of globalThis.__gencow_tableAccessRegistry || []) {
314
+ if (meta.tableName === name)
315
+ return meta;
316
+ }
317
+ return undefined;
318
+ }
319
+ // ─── fieldAccess post-processing ────────────────────────
320
+ /**
321
+ * Apply field-level access control to query results.
322
+ * Nullifies fields that the current user is not authorized to read.
323
+ *
324
+ * @param result - Query result (array or single object)
325
+ * @param table - The gencowTable used in the query
326
+ * @param ctx - GencowCtx for auth checks
327
+ * @returns Filtered result with unauthorized fields set to null
328
+ */
329
+ export function applyFieldAccess(result, table, ctx) {
330
+ const meta = getTableAccessMeta(table);
331
+ if (!meta?.fieldAccess)
332
+ return result;
333
+ const fieldAccess = meta.fieldAccess;
334
+ // Determine which fields to mask
335
+ const maskedFields = [];
336
+ for (const [field, rule] of Object.entries(fieldAccess)) {
337
+ try {
338
+ if (!rule.read(ctx)) {
339
+ maskedFields.push(field);
340
+ }
341
+ }
342
+ catch {
343
+ // If read check throws (e.g., requireAuth on anonymous), mask the field
344
+ maskedFields.push(field);
345
+ }
346
+ }
347
+ if (maskedFields.length === 0)
348
+ return result;
349
+ const maskRow = (row) => {
350
+ if (!row || typeof row !== "object")
351
+ return row;
352
+ const masked = { ...row };
353
+ for (const field of maskedFields) {
354
+ if (field in masked) {
355
+ masked[field] = null;
356
+ }
357
+ }
358
+ return masked;
359
+ };
360
+ if (Array.isArray(result)) {
361
+ return result.map(maskRow);
362
+ }
363
+ return maskRow(result);
364
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * packages/core/src/table.ts
3
+ *
4
+ * gencowTable() — Drizzle pgTable wrapper with schema-level access control.
5
+ * Inspired by KeystoneJS's declarative filter pattern.
6
+ *
7
+ * Run tests: bun test packages/core/src/__tests__/table.test.ts
8
+ */
9
+ import { pgTable } from "drizzle-orm/pg-core";
10
+ import type { GencowCtx } from "./reactive";
11
+ import type { SQL } from "drizzle-orm";
12
+ /**
13
+ * Access control filter function.
14
+ * Returns a Drizzle SQL condition, `true` (allow all), or `false` (deny all).
15
+ * Can be async (e.g., for team membership lookups).
16
+ */
17
+ export type AccessFilter = (ctx: GencowCtx) => SQL | boolean | Promise<SQL | boolean>;
18
+ /** Field-level access control: per-field read permission. */
19
+ export interface FieldAccessRule {
20
+ read: (ctx: GencowCtx) => boolean;
21
+ }
22
+ /** Options for gencowTable — access control metadata. */
23
+ export interface GencowTableOptions {
24
+ /** Row-level filter — auto-injected into all queries on this table */
25
+ filter: AccessFilter;
26
+ /** Field-level access — unauthorized fields are nullified in results */
27
+ fieldAccess?: Record<string, FieldAccessRule>;
28
+ }
29
+ /** Stored metadata for a gencowTable. */
30
+ export interface TableAccessMeta {
31
+ filter: AccessFilter;
32
+ fieldAccess?: Record<string, FieldAccessRule>;
33
+ tableName: string;
34
+ }
35
+ declare global {
36
+ var __gencow_tableAccessRegistry: Map<any, TableAccessMeta>;
37
+ }
38
+ /**
39
+ * Drizzle pgTable wrapper with schema-level access control.
40
+ *
41
+ * Creates a standard Drizzle pgTable (100% compatible) and registers
42
+ * filter + fieldAccess metadata that ctx.db Proxy uses to auto-inject
43
+ * WHERE clauses.
44
+ *
45
+ * @param name - PostgreSQL table name
46
+ * @param columns - Drizzle column definitions (same as pgTable)
47
+ * @param options - Access control (filter required, fieldAccess optional)
48
+ */
49
+ export declare function gencowTable<T extends Record<string, any>>(name: string, columns: T, options: GencowTableOptions): ReturnType<typeof pgTable<string, T>>;
50
+ /**
51
+ * Convenience helper for userId-based isolation.
52
+ *
53
+ * Usage:
54
+ * export const tasks = gencowTable("tasks", { ... }, ownerFilter("userId"));
55
+ * export const files = gencowTable("files", { ... }, ownerFilter("ownerId"));
56
+ *
57
+ * @param columnName - Name of the user ID column (default: "userId")
58
+ */
59
+ export declare function ownerFilter(columnName?: string): GencowTableOptions;
60
+ /** Get access control metadata for a table. Returns undefined for plain pgTable. */
61
+ export declare function getTableAccessMeta(table: any): TableAccessMeta | undefined;
62
+ /** Check if a table has gencowTable metadata registered. */
63
+ export declare function isGencowTable(table: any): boolean;
64
+ /** Get all registered gencowTables (for audit/boot-time checks). */
65
+ export declare function getAllGencowTables(): Map<any, TableAccessMeta>;
66
+ /** Reset registry (for testing only). */
67
+ export declare function _resetTableRegistry(): void;
package/dist/table.js ADDED
@@ -0,0 +1,98 @@
1
+ /**
2
+ * packages/core/src/table.ts
3
+ *
4
+ * gencowTable() — Drizzle pgTable wrapper with schema-level access control.
5
+ * Inspired by KeystoneJS's declarative filter pattern.
6
+ *
7
+ * Run tests: bun test packages/core/src/__tests__/table.test.ts
8
+ */
9
+ import { pgTable } from "drizzle-orm/pg-core";
10
+ import { eq } from "drizzle-orm";
11
+ function isOwnerFilter(opts) {
12
+ return "_ownerColumn" in opts;
13
+ }
14
+ if (!globalThis.__gencow_tableAccessRegistry) {
15
+ globalThis.__gencow_tableAccessRegistry = new Map();
16
+ }
17
+ const tableAccessRegistry = globalThis.__gencow_tableAccessRegistry;
18
+ // ─── gencowTable() ──────────────────────────────────────
19
+ /**
20
+ * Drizzle pgTable wrapper with schema-level access control.
21
+ *
22
+ * Creates a standard Drizzle pgTable (100% compatible) and registers
23
+ * filter + fieldAccess metadata that ctx.db Proxy uses to auto-inject
24
+ * WHERE clauses.
25
+ *
26
+ * @param name - PostgreSQL table name
27
+ * @param columns - Drizzle column definitions (same as pgTable)
28
+ * @param options - Access control (filter required, fieldAccess optional)
29
+ */
30
+ export function gencowTable(name, columns, options) {
31
+ if (!options || (typeof options.filter !== "function" && !isOwnerFilter(options))) {
32
+ throw new Error(`[gencow] gencowTable("${name}") requires a filter option. ` +
33
+ `Use ownerFilter("userId") for simple user isolation, ` +
34
+ `or { filter: () => true } for public tables.`);
35
+ }
36
+ // Create standard Drizzle pgTable — fully compatible
37
+ const table = pgTable(name, columns);
38
+ // Resolve ownerFilter to a concrete filter bound to this table
39
+ let filter;
40
+ if (isOwnerFilter(options)) {
41
+ const columnName = options._ownerColumn;
42
+ const col = table[columnName];
43
+ if (!col) {
44
+ throw new Error(`[gencow] ownerFilter("${columnName}"): column "${columnName}" not found on table "${name}". ` +
45
+ `Available columns: ${Object.keys(table).filter(k => !k.startsWith("_") && !k.startsWith("$")).join(", ")}`);
46
+ }
47
+ filter = (ctx) => {
48
+ const user = ctx.auth.requireAuth();
49
+ return eq(col, user.id);
50
+ };
51
+ }
52
+ else {
53
+ filter = options.filter;
54
+ }
55
+ // Store access control metadata keyed by the table object reference
56
+ tableAccessRegistry.set(table, {
57
+ filter,
58
+ fieldAccess: options.fieldAccess,
59
+ tableName: name,
60
+ });
61
+ return table;
62
+ }
63
+ // ─── ownerFilter() helper ───────────────────────────────
64
+ /**
65
+ * Convenience helper for userId-based isolation.
66
+ *
67
+ * Usage:
68
+ * export const tasks = gencowTable("tasks", { ... }, ownerFilter("userId"));
69
+ * export const files = gencowTable("files", { ... }, ownerFilter("ownerId"));
70
+ *
71
+ * @param columnName - Name of the user ID column (default: "userId")
72
+ */
73
+ export function ownerFilter(columnName = "userId") {
74
+ // Return a marker object — gencowTable() resolves this to a concrete filter
75
+ // by binding to the actual table column at registration time.
76
+ return {
77
+ _ownerColumn: columnName,
78
+ // Placeholder filter — replaced by gencowTable()
79
+ filter: () => { throw new Error("[gencow] ownerFilter placeholder should not be called directly"); },
80
+ };
81
+ }
82
+ // ─── Lookup ─────────────────────────────────────────────
83
+ /** Get access control metadata for a table. Returns undefined for plain pgTable. */
84
+ export function getTableAccessMeta(table) {
85
+ return tableAccessRegistry.get(table);
86
+ }
87
+ /** Check if a table has gencowTable metadata registered. */
88
+ export function isGencowTable(table) {
89
+ return tableAccessRegistry.has(table);
90
+ }
91
+ /** Get all registered gencowTables (for audit/boot-time checks). */
92
+ export function getAllGencowTables() {
93
+ return new Map(tableAccessRegistry);
94
+ }
95
+ /** Reset registry (for testing only). */
96
+ export function _resetTableRegistry() {
97
+ tableAccessRegistry.clear();
98
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gencow/core",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "description": "Gencow core library — defineQuery, defineMutation, reactive subscriptions",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -131,7 +131,6 @@ describe("crud() + 수동 query/mutation 혼용", () => {
131
131
  });
132
132
 
133
133
  mutation("cg_digests.generate", {
134
- invalidates: ["cg_digests.list"],
135
134
  public: true,
136
135
  handler: async (ctx) => {
137
136
  return { success: true };
@@ -9,7 +9,6 @@
9
9
  import { describe, it, expect } from "bun:test";
10
10
  import {
11
11
  buildRealtimeCtx,
12
- invalidateQueries,
13
12
  subscribe,
14
13
  registerClient,
15
14
  deregisterClient,
@@ -320,44 +319,40 @@ describe("[Load] 대용량 페이로드 emit 성능", () => {
320
319
  });
321
320
  });
322
321
 
323
- // ─── 6. invalidateQueries broadcast 확장성 ──────────────────────────────────
322
+ // ─── 6. emit broadcast 확장성 (컨넥튰드 클라이언트 기반) ─────────────────────────
324
323
 
325
- describe("[Load] invalidateQueries broadcast 확장성", () => {
326
- it("1,000개 connectedClients에 broadcast를 50ms 이내에 완료한다", async () => {
324
+ describe("[Load] emit broadcast 확장성", () => {
325
+ it("1,000개 구독자에게 emit이 50ms 이내에 완료된다", async () => {
327
326
  const N = 1_000;
327
+ const key = makeUniqueKey("broadcast1k");
328
328
  const clients = Array.from({ length: N }, (_, i) => makeMockWs(i));
329
- clients.forEach(ws => registerClient(ws));
329
+ clients.forEach(ws => subscribe(key, ws));
330
330
 
331
- const mockCtx = {} as GencowCtx;
332
331
  const start = performance.now();
333
- await invalidateQueries(["tasks.list", "tasks.get"], mockCtx);
332
+ buildRealtimeCtx().emit(key, [{ id: 1 }, { id: 2 }]);
333
+ await wait(60);
334
334
  const elapsed = performance.now() - start;
335
335
 
336
- console.log(`[broadcast] 1,000 connectedClients → ${elapsed.toFixed(1)}ms`);
336
+ console.log(`[broadcast] 1,000 subscribers → ${elapsed.toFixed(1)}ms`);
337
337
 
338
338
  const received = clients.filter(ws => ws.sendCount === 1).length;
339
339
  expect(received).toBe(N);
340
- expect(elapsed).toBeLessThan(50);
340
+ expect(elapsed).toBeLessThan(100); // emit has 50ms debounce
341
341
 
342
342
  clients.forEach(ws => deregisterClient(ws));
343
343
  });
344
344
  });
345
345
 
346
- // ─── 7. push vs legacy 비교 ─────────────────────────────────────────────────
346
+ // ─── 7. Push emit 포맷 검증 ───────────────────────────────────────────────
347
347
 
348
- describe("[Load] Push 방식 vs Legacy invalidateQueries 비교", () => {
349
- it("emit(push) query:updated+data를, legacy는 invalidate 신호만 전달한다", async () => {
348
+ describe("[Load] Push emit 포맷 검증", () => {
349
+ it("emit(push) query:updated+data 실제 페이로드와 함께 전달한다", async () => {
350
350
  const N_SUBS = 500;
351
351
  const keyPush = makeUniqueKey("compare.push");
352
- const keyLegacy = makeUniqueKey("compare.legacy");
353
352
 
354
353
  // push: 구독 클라이언트 (query:updated 수신 예상)
355
354
  const pushClients = Array.from({ length: N_SUBS }, (_, i) => makeTrackedWs(i));
356
- // legacy: connectedClients 전용 (invalidate broadcast 수신 예상)
357
- const legacyClients = Array.from({ length: N_SUBS }, (_, i) => makeTrackedWs(i + N_SUBS));
358
-
359
355
  pushClients.forEach(ws => subscribe(keyPush, ws));
360
- legacyClients.forEach(ws => registerClient(ws)); // subscribe 안 함 → invalidate만 수신
361
356
 
362
357
  const data = Array.from({ length: 100 }, (_, i) => ({ id: i, title: `T${i}` }));
363
358
 
@@ -367,28 +362,15 @@ describe("[Load] Push 방식 vs Legacy invalidateQueries 비교", () => {
367
362
  await wait(80);
368
363
  const pushElapsed = performance.now() - pushStart;
369
364
 
370
- // Legacy path: invalidate broadcast 즉시 전송
371
- const legacyStart = performance.now();
372
- await invalidateQueries([keyLegacy], {} as GencowCtx);
373
- const legacyElapsed = performance.now() - legacyStart;
374
-
375
365
  const pushWithData = pushClients.filter(ws =>
376
366
  ws.received.some((m: any) => m.type === "query:updated" && m.hasData)
377
367
  ).length;
378
368
 
379
- const legacyWithSignal = legacyClients.filter(ws =>
380
- ws.received.some((m: any) => m.type === "invalidate")
381
- ).length;
382
-
383
- console.log(`[compare] Push : ${pushElapsed.toFixed(1)}ms → ${pushWithData}/${N_SUBS} with data (query:updated)`);
384
- console.log(`[compare] Legacy: ${legacyElapsed.toFixed(1)}ms → ${legacyWithSignal}/${N_SUBS} signal only (invalidate)`);
369
+ console.log(`[push] ${pushElapsed.toFixed(1)}ms ${pushWithData}/${N_SUBS} with data (query:updated)`);
385
370
 
386
371
  // 핵심 검증
387
372
  expect(pushWithData).toBe(N_SUBS); // 500/500이 data 포함 메시지 수신
388
- expect(legacyWithSignal).toBe(N_SUBS); // 500/500이 신호 수신
389
- expect(legacyElapsed).toBeLessThan(pushElapsed); // legacy가 더 즉각적 (debounce 없음)
390
373
 
391
374
  pushClients.forEach(ws => deregisterClient(ws));
392
- legacyClients.forEach(ws => deregisterClient(ws));
393
375
  });
394
376
  });