@gencow/core 0.1.21 → 0.1.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.
- package/dist/crud.d.ts +12 -12
- package/dist/crud.js +4 -4
- package/dist/index.d.ts +19 -18
- package/dist/index.js +10 -10
- package/dist/reactive.d.ts +4 -4
- package/dist/reactive.js +6 -0
- package/dist/rls-db.d.ts +43 -4
- package/dist/rls-db.js +212 -7
- package/dist/rls.d.ts +1 -1
- package/dist/rls.js +1 -1
- package/dist/scheduler.d.ts +35 -5
- package/dist/scheduler.js +83 -42
- package/dist/server.d.ts +5 -5
- package/dist/server.js +4 -4
- package/package.json +43 -42
- package/src/__tests__/crud-owner-rls.test.ts +6 -6
- package/src/__tests__/fixtures/basic/migrations/{0000_faithful_silver_sable.sql → 0000_last_warstar.sql} +9 -0
- package/src/__tests__/fixtures/basic/migrations/meta/0000_snapshot.json +60 -1
- package/src/__tests__/fixtures/basic/migrations/meta/_journal.json +2 -2
- package/src/__tests__/fixtures/basic/schema.ts +19 -3
- package/src/__tests__/helpers/basic-rls-fixture.ts +133 -0
- package/src/__tests__/helpers/test-gencow-ctx-rls.ts +1 -1
- package/src/__tests__/reactive.test.ts +161 -0
- package/src/__tests__/rls-crud-basic.test.ts +120 -161
- package/src/__tests__/rls-crud-no-owner-rls-pglite.test.ts +117 -0
- package/src/__tests__/rls-custom-mutation-handlers.test.ts +189 -0
- package/src/__tests__/rls-custom-query-handlers.test.ts +128 -0
- package/src/__tests__/rls-db-leased-connection.test.ts +122 -0
- package/src/__tests__/rls-session-and-policies.test.ts +246 -0
- package/src/__tests__/scheduler-durable-v2.test.ts +270 -0
- package/src/__tests__/scheduler-durable.test.ts +173 -0
- package/src/crud.ts +4 -4
- package/src/index.ts +19 -18
- package/src/reactive.ts +12 -4
- package/src/rls-db.ts +277 -10
- package/src/rls.ts +1 -1
- package/src/scheduler.ts +124 -46
- package/src/server.ts +5 -5
package/dist/scheduler.js
CHANGED
|
@@ -11,8 +11,9 @@ export function getSchedulerInfo() {
|
|
|
11
11
|
failedJobs: _failedJobs,
|
|
12
12
|
};
|
|
13
13
|
}
|
|
14
|
-
export function createScheduler() {
|
|
14
|
+
export function createScheduler(options) {
|
|
15
15
|
const timers = new Map();
|
|
16
|
+
const isDurable = !!(options?.persistJob && options?.removeJob);
|
|
16
17
|
const cronJobs = new Map();
|
|
17
18
|
const actions = new Map();
|
|
18
19
|
let jobCounter = 0;
|
|
@@ -42,51 +43,75 @@ export function createScheduler() {
|
|
|
42
43
|
await handler(args);
|
|
43
44
|
console.log(`[scheduler] Action "${action}" completed`);
|
|
44
45
|
}
|
|
46
|
+
/** @internal 인메모리 setTimeout 기반 스케줄링 (로컬 dev + durable fallback) */
|
|
47
|
+
function scheduleInMemory(id, ms, action, args, scheduleOpts, jobEntry) {
|
|
48
|
+
const timer = setTimeout(async () => {
|
|
49
|
+
jobEntry.status = "running";
|
|
50
|
+
try {
|
|
51
|
+
await executeAction(action, args);
|
|
52
|
+
jobEntry.status = "completed";
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
56
|
+
jobEntry.status = "failed";
|
|
57
|
+
console.error(`[scheduler] Action "${action}" failed (job: ${id}):`, err.message);
|
|
58
|
+
// 실패 기록 보관 (dead-letter)
|
|
59
|
+
recordFailure(id, action, args, err);
|
|
60
|
+
// onError 콜백 실행 — 다른 action에 에러 정보 전달
|
|
61
|
+
if (scheduleOpts?.onError) {
|
|
62
|
+
try {
|
|
63
|
+
await executeAction(scheduleOpts.onError, {
|
|
64
|
+
failedAction: action,
|
|
65
|
+
failedJobId: id,
|
|
66
|
+
error: err.message,
|
|
67
|
+
originalArgs: args,
|
|
68
|
+
});
|
|
69
|
+
console.log(`[scheduler] onError handler "${scheduleOpts.onError}" completed for "${action}"`);
|
|
70
|
+
}
|
|
71
|
+
catch (onErrorErr) {
|
|
72
|
+
console.error(`[scheduler] onError handler "${scheduleOpts.onError}" also failed:`, onErrorErr);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
timers.delete(id);
|
|
78
|
+
// completed/failed 기록은 잠시 유지 후 정리 (status 조회 가능하도록)
|
|
79
|
+
setTimeout(() => {
|
|
80
|
+
const idx = _pendingJobs.findIndex((j) => j.id === id);
|
|
81
|
+
if (idx >= 0)
|
|
82
|
+
_pendingJobs.splice(idx, 1);
|
|
83
|
+
}, 60_000); // 1분 후 정리
|
|
84
|
+
}
|
|
85
|
+
}, ms);
|
|
86
|
+
timers.set(id, timer);
|
|
87
|
+
console.log(`[scheduler] Scheduled "${action}" to run after ${ms}ms (id: ${id})${scheduleOpts?.onError ? ` [onError: ${scheduleOpts.onError}]` : ""}`);
|
|
88
|
+
}
|
|
45
89
|
return {
|
|
46
|
-
runAfter(ms, action, args,
|
|
90
|
+
runAfter(ms, action, args, scheduleOpts) {
|
|
47
91
|
const id = generateId();
|
|
48
92
|
const jobEntry = { id, action, scheduledAt: new Date().toISOString(), status: "pending" };
|
|
49
93
|
_pendingJobs.push(jobEntry);
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
console.log(`[scheduler] onError handler "${options.onError}" completed for "${action}"`);
|
|
72
|
-
}
|
|
73
|
-
catch (onErrorErr) {
|
|
74
|
-
console.error(`[scheduler] onError handler "${options.onError}" also failed:`, onErrorErr);
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
finally {
|
|
79
|
-
timers.delete(id);
|
|
80
|
-
// completed/failed 기록은 잠시 유지 후 정리 (status 조회 가능하도록)
|
|
81
|
-
setTimeout(() => {
|
|
82
|
-
const idx = _pendingJobs.findIndex((j) => j.id === id);
|
|
83
|
-
if (idx >= 0)
|
|
84
|
-
_pendingJobs.splice(idx, 1);
|
|
85
|
-
}, 60_000); // 1분 후 정리
|
|
86
|
-
}
|
|
87
|
-
}, ms);
|
|
88
|
-
timers.set(id, timer);
|
|
89
|
-
console.log(`[scheduler] Scheduled "${action}" to run after ${ms}ms (id: ${id})${options?.onError ? ` [onError: ${options.onError}]` : ""}`);
|
|
94
|
+
// ── Durable mode: DB에 영속화, 실행은 외부 폴러에 위임 ──
|
|
95
|
+
if (isDurable) {
|
|
96
|
+
const runAt = new Date(Date.now() + ms);
|
|
97
|
+
options.persistJob({
|
|
98
|
+
id,
|
|
99
|
+
action,
|
|
100
|
+
args: args ?? {},
|
|
101
|
+
runAt,
|
|
102
|
+
onErrorAction: scheduleOpts?.onError,
|
|
103
|
+
}).then(() => {
|
|
104
|
+
console.log(`[scheduler] Persisted "${action}" to run at ${runAt.toISOString()} (id: ${id}, durable)` +
|
|
105
|
+
`${scheduleOpts?.onError ? ` [onError: ${scheduleOpts.onError}]` : ""}`);
|
|
106
|
+
}).catch((err) => {
|
|
107
|
+
console.error(`[scheduler] Failed to persist job ${id}:`, err instanceof Error ? err.message : err);
|
|
108
|
+
// DB persist 실패 → 인메모리 fallback
|
|
109
|
+
scheduleInMemory(id, ms, action, args, scheduleOpts, jobEntry);
|
|
110
|
+
});
|
|
111
|
+
return id;
|
|
112
|
+
}
|
|
113
|
+
// ── 인메모리 mode: 기존 setTimeout 동작 ──
|
|
114
|
+
scheduleInMemory(id, ms, action, args, scheduleOpts, jobEntry);
|
|
90
115
|
return id;
|
|
91
116
|
},
|
|
92
117
|
runAt(timestamp, action, args, options) {
|
|
@@ -95,6 +120,22 @@ export function createScheduler() {
|
|
|
95
120
|
return this.runAfter(ms, action, args, options);
|
|
96
121
|
},
|
|
97
122
|
cancel(jobId) {
|
|
123
|
+
// ── Durable mode: DB에서도 제거 ──
|
|
124
|
+
if (isDurable) {
|
|
125
|
+
const idx = _pendingJobs.findIndex((j) => j.id === jobId);
|
|
126
|
+
if (idx >= 0) {
|
|
127
|
+
_pendingJobs.splice(idx, 1);
|
|
128
|
+
options.removeJob(jobId).catch((err) => {
|
|
129
|
+
console.error(`[scheduler] Failed to remove persisted job ${jobId}:`, err instanceof Error ? err.message : err);
|
|
130
|
+
});
|
|
131
|
+
console.log(`[scheduler] Cancelled job ${jobId} (durable)`);
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
// pendingJobs에 없어도 DB에는 있을 수 있으므로 삭제 시도
|
|
135
|
+
options.removeJob(jobId).catch(() => { });
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
// ── 인메모리 mode ──
|
|
98
139
|
const timer = timers.get(jobId);
|
|
99
140
|
if (timer) {
|
|
100
141
|
clearTimeout(timer);
|
package/dist/server.d.ts
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* executing server. Excluded from client-side core (`index.ts`) so they aren't
|
|
6
6
|
* bundled into user functions which run in Firecracker.
|
|
7
7
|
*/
|
|
8
|
-
export { createDb } from "./db";
|
|
9
|
-
export { createStorage, storageRoutes } from "./storage";
|
|
10
|
-
export type { StorageImageTierConfig } from "./storage";
|
|
11
|
-
export { createScheduler, getSchedulerInfo } from "./scheduler";
|
|
12
|
-
export { authMiddleware, authRoutes, getUsers } from "./auth";
|
|
8
|
+
export { createDb } from "./db.js";
|
|
9
|
+
export { createStorage, storageRoutes } from "./storage.js";
|
|
10
|
+
export type { StorageImageTierConfig } from "./storage.js";
|
|
11
|
+
export { createScheduler, getSchedulerInfo } from "./scheduler.js";
|
|
12
|
+
export { authMiddleware, authRoutes, getUsers } from "./auth.js";
|
package/dist/server.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* executing server. Excluded from client-side core (`index.ts`) so they aren't
|
|
6
6
|
* bundled into user functions which run in Firecracker.
|
|
7
7
|
*/
|
|
8
|
-
export { createDb } from "./db";
|
|
9
|
-
export { createStorage, storageRoutes } from "./storage";
|
|
10
|
-
export { createScheduler, getSchedulerInfo } from "./scheduler";
|
|
11
|
-
export { authMiddleware, authRoutes, getUsers } from "./auth";
|
|
8
|
+
export { createDb } from "./db.js";
|
|
9
|
+
export { createStorage, storageRoutes } from "./storage.js";
|
|
10
|
+
export { createScheduler, getSchedulerInfo } from "./scheduler.js";
|
|
11
|
+
export { authMiddleware, authRoutes, getUsers } from "./auth.js";
|
package/package.json
CHANGED
|
@@ -1,45 +1,46 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
2
|
+
"name": "@gencow/core",
|
|
3
|
+
"version": "0.1.23",
|
|
4
|
+
"description": "Gencow core library — defineQuery, defineMutation, reactive subscriptions",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"require": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts"
|
|
13
|
+
},
|
|
14
|
+
"./server": {
|
|
15
|
+
"import": "./dist/server.js",
|
|
16
|
+
"require": "./dist/server.js",
|
|
17
|
+
"types": "./dist/server.d.ts"
|
|
18
|
+
}
|
|
13
19
|
},
|
|
14
|
-
"
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
20
|
+
"files": [
|
|
21
|
+
"dist/",
|
|
22
|
+
"src/"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"db:generate:fixture-basic": "drizzle-kit generate --config ./src/__tests__/fixtures/basic/drizzle.config.ts",
|
|
26
|
+
"build": "tsc",
|
|
27
|
+
"typecheck": "tsc --noEmit",
|
|
28
|
+
"prepublishOnly": "npm run build",
|
|
29
|
+
"postinstall": "tsc"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@electric-sql/pglite": "^0.3.15",
|
|
33
|
+
"drizzle-orm": "^0.45.1",
|
|
34
|
+
"hono": "^4.12.0",
|
|
35
|
+
"node-cron": "^4.2.1"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/bun": "^1.3.9",
|
|
39
|
+
"@types/node": "^25.3.0",
|
|
40
|
+
"@types/node-cron": "^3.0.11",
|
|
41
|
+
"drizzle-kit": "^0.31.10",
|
|
42
|
+
"drizzle-seed": "^0.3.1",
|
|
43
|
+
"typescript": "^5.9.3",
|
|
44
|
+
"uuid": "^13.0.0"
|
|
18
45
|
}
|
|
19
|
-
|
|
20
|
-
"files": [
|
|
21
|
-
"dist/",
|
|
22
|
-
"src/"
|
|
23
|
-
],
|
|
24
|
-
"dependencies": {
|
|
25
|
-
"@electric-sql/pglite": "^0.3.15",
|
|
26
|
-
"drizzle-orm": "^0.45.1",
|
|
27
|
-
"hono": "^4.12.0",
|
|
28
|
-
"node-cron": "^4.2.1"
|
|
29
|
-
},
|
|
30
|
-
"devDependencies": {
|
|
31
|
-
"@types/bun": "^1.3.9",
|
|
32
|
-
"@types/node": "^25.3.0",
|
|
33
|
-
"@types/node-cron": "^3.0.11",
|
|
34
|
-
"drizzle-kit": "^0.31.10",
|
|
35
|
-
"drizzle-seed": "^0.3.1",
|
|
36
|
-
"typescript": "^5.9.3",
|
|
37
|
-
"uuid": "^13.0.0"
|
|
38
|
-
},
|
|
39
|
-
"scripts": {
|
|
40
|
-
"db:generate:fixture-basic": "drizzle-kit generate --config ./src/__tests__/fixtures/basic/drizzle.config.ts",
|
|
41
|
-
"build": "tsc",
|
|
42
|
-
"typecheck": "tsc --noEmit",
|
|
43
|
-
"postinstall": "tsc"
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
+
}
|
|
@@ -236,17 +236,17 @@ describe("crud() + ownerRls — 데이터 격리", () => {
|
|
|
236
236
|
expect(values.userId).toBe("user-A");
|
|
237
237
|
});
|
|
238
238
|
|
|
239
|
-
it("create:
|
|
239
|
+
it("create: 타인 user_id 주입 시도는 거부되고 insert까지 가지 않음 (보안)", async () => {
|
|
240
240
|
const mutations = getRegisteredMutations();
|
|
241
241
|
const createDef = mutations.find((m: any) => m.name === "rls_tasks.create");
|
|
242
242
|
|
|
243
243
|
const { ctx, getCapturedValues } = createMockCtx("user-A");
|
|
244
|
-
// 해커가 user_id를 "hacker-id"로 조작 시도
|
|
245
|
-
await
|
|
244
|
+
// 해커가 user_id를 "hacker-id"로 조작 시도 — Layer 1은 즉시 Forbidden (덮어쓰기 전 차단)
|
|
245
|
+
await expect(
|
|
246
|
+
createDef!.handler(ctx, { title: "Spoofed", user_id: "hacker-id" }),
|
|
247
|
+
).rejects.toThrow("Forbidden: cannot create resource for another user");
|
|
246
248
|
|
|
247
|
-
|
|
248
|
-
// 인증된 사용자 ID로 강제 덮어씀 (JS 프로퍼티명)
|
|
249
|
-
expect(values.userId).toBe("user-A");
|
|
249
|
+
expect(getCapturedValues()).toBeNull();
|
|
250
250
|
});
|
|
251
251
|
|
|
252
252
|
// ── update 격리 ──
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
CREATE TABLE "news" (
|
|
2
|
+
"id" text PRIMARY KEY NOT NULL,
|
|
3
|
+
"title" text NOT NULL,
|
|
4
|
+
"user_id" text NOT NULL,
|
|
5
|
+
"created_at" timestamp DEFAULT now() NOT NULL,
|
|
6
|
+
"updated_at" timestamp DEFAULT now() NOT NULL
|
|
7
|
+
);
|
|
8
|
+
--> statement-breakpoint
|
|
1
9
|
CREATE TABLE "tasks" (
|
|
2
10
|
"id" text PRIMARY KEY NOT NULL,
|
|
3
11
|
"title" text NOT NULL,
|
|
@@ -57,6 +65,7 @@ CREATE TABLE "verification" (
|
|
|
57
65
|
"updated_at" timestamp DEFAULT now()
|
|
58
66
|
);
|
|
59
67
|
--> statement-breakpoint
|
|
68
|
+
ALTER TABLE "news" ADD CONSTRAINT "news_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
60
69
|
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
61
70
|
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
62
71
|
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
@@ -1,9 +1,68 @@
|
|
|
1
1
|
{
|
|
2
|
-
"id": "
|
|
2
|
+
"id": "d57dfdaa-8d90-493e-834f-580b33548adc",
|
|
3
3
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
|
4
4
|
"version": "7",
|
|
5
5
|
"dialect": "postgresql",
|
|
6
6
|
"tables": {
|
|
7
|
+
"public.news": {
|
|
8
|
+
"name": "news",
|
|
9
|
+
"schema": "",
|
|
10
|
+
"columns": {
|
|
11
|
+
"id": {
|
|
12
|
+
"name": "id",
|
|
13
|
+
"type": "text",
|
|
14
|
+
"primaryKey": true,
|
|
15
|
+
"notNull": true
|
|
16
|
+
},
|
|
17
|
+
"title": {
|
|
18
|
+
"name": "title",
|
|
19
|
+
"type": "text",
|
|
20
|
+
"primaryKey": false,
|
|
21
|
+
"notNull": true
|
|
22
|
+
},
|
|
23
|
+
"user_id": {
|
|
24
|
+
"name": "user_id",
|
|
25
|
+
"type": "text",
|
|
26
|
+
"primaryKey": false,
|
|
27
|
+
"notNull": true
|
|
28
|
+
},
|
|
29
|
+
"created_at": {
|
|
30
|
+
"name": "created_at",
|
|
31
|
+
"type": "timestamp",
|
|
32
|
+
"primaryKey": false,
|
|
33
|
+
"notNull": true,
|
|
34
|
+
"default": "now()"
|
|
35
|
+
},
|
|
36
|
+
"updated_at": {
|
|
37
|
+
"name": "updated_at",
|
|
38
|
+
"type": "timestamp",
|
|
39
|
+
"primaryKey": false,
|
|
40
|
+
"notNull": true,
|
|
41
|
+
"default": "now()"
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"indexes": {},
|
|
45
|
+
"foreignKeys": {
|
|
46
|
+
"news_user_id_user_id_fk": {
|
|
47
|
+
"name": "news_user_id_user_id_fk",
|
|
48
|
+
"tableFrom": "news",
|
|
49
|
+
"tableTo": "user",
|
|
50
|
+
"columnsFrom": [
|
|
51
|
+
"user_id"
|
|
52
|
+
],
|
|
53
|
+
"columnsTo": [
|
|
54
|
+
"id"
|
|
55
|
+
],
|
|
56
|
+
"onDelete": "cascade",
|
|
57
|
+
"onUpdate": "no action"
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"compositePrimaryKeys": {},
|
|
61
|
+
"uniqueConstraints": {},
|
|
62
|
+
"policies": {},
|
|
63
|
+
"checkConstraints": {},
|
|
64
|
+
"isRLSEnabled": false
|
|
65
|
+
},
|
|
7
66
|
"public.tasks": {
|
|
8
67
|
"name": "tasks",
|
|
9
68
|
"schema": "",
|
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
*
|
|
8
8
|
* 변경 후: gencow dev가 자동 반영
|
|
9
9
|
*/
|
|
10
|
-
import { ownerRls } from "../../../rls";
|
|
10
|
+
import { ownerRls } from "../../../rls.js";
|
|
11
11
|
import { pgTable } from "drizzle-orm/pg-core";
|
|
12
12
|
import { text, boolean, timestamp } from "drizzle-orm/pg-core";
|
|
13
|
-
import { user } from "../common/auth-schema";
|
|
13
|
+
import { user } from "../common/auth-schema.js";
|
|
14
14
|
import { v4 as uuidv4 } from "uuid";
|
|
15
15
|
|
|
16
|
-
export { user } from "../common/auth-schema";
|
|
16
|
+
export { user } from "../common/auth-schema.js";
|
|
17
17
|
|
|
18
18
|
export const tasks = pgTable(
|
|
19
19
|
"tasks",
|
|
@@ -33,3 +33,19 @@ export const tasks = pgTable(
|
|
|
33
33
|
},
|
|
34
34
|
(t) => ownerRls(t.userId)
|
|
35
35
|
);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Public-ish content table **without** `ownerRls()` — no PostgreSQL RLS policies; used for
|
|
39
|
+
* `crud(..., { public: true })` + no-DB-RLS integration tests.
|
|
40
|
+
*/
|
|
41
|
+
export const news = pgTable("news", {
|
|
42
|
+
id: text("id")
|
|
43
|
+
.primaryKey()
|
|
44
|
+
.$defaultFn(() => uuidv4()),
|
|
45
|
+
title: text("title").notNull(),
|
|
46
|
+
userId: text("user_id")
|
|
47
|
+
.notNull()
|
|
48
|
+
.references(() => user.id, { onDelete: "cascade" }),
|
|
49
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
50
|
+
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
|
51
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared PGlite + `fixtures/basic` schema seed + RLS app role for integration tests.
|
|
3
|
+
*/
|
|
4
|
+
import { dirname, join } from "path";
|
|
5
|
+
import { fileURLToPath } from "url";
|
|
6
|
+
import type { InferSelectModel } from "drizzle-orm";
|
|
7
|
+
import { getTableName, sql } from "drizzle-orm";
|
|
8
|
+
import type { PgTable } from "drizzle-orm/pg-core";
|
|
9
|
+
import { PGlite } from "@electric-sql/pglite";
|
|
10
|
+
import { drizzle } from "drizzle-orm/pglite";
|
|
11
|
+
|
|
12
|
+
import type { UserIdentity } from "../../reactive.js";
|
|
13
|
+
import { news, tasks, user } from "../fixtures/basic/schema.js";
|
|
14
|
+
import { createPgliteRlsAppRole, DEFAULT_PGLITE_RLS_APP_ROLE, setPgliteSessionRole } from "./pglite-rls-session.js";
|
|
15
|
+
import { loadAndApplyMigrations } from "./pglite-migrations.js";
|
|
16
|
+
import { fillPartialRowsForInsert } from "./seed-like-fill.js";
|
|
17
|
+
|
|
18
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
|
|
20
|
+
export const basicFixtureUsers = [
|
|
21
|
+
{ id: "us_000", name: "User 0", email: "user-0@s.com", emailVerified: true },
|
|
22
|
+
{ id: "us_001", name: "User 1", email: "user-1@s.com", emailVerified: true },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/** Stable ids/titles; overlaps across users for search / RLS tests. */
|
|
26
|
+
export const basicFixtureTasks = [
|
|
27
|
+
{
|
|
28
|
+
id: "tk-000",
|
|
29
|
+
userId: basicFixtureUsers[0].id,
|
|
30
|
+
done: false,
|
|
31
|
+
title: "Project Alpha — Q4 review prep",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: "tk-001",
|
|
35
|
+
userId: basicFixtureUsers[1].id,
|
|
36
|
+
done: true,
|
|
37
|
+
title: "Project Alpha — teammate handoff",
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: "tk-002",
|
|
41
|
+
userId: basicFixtureUsers[0].id,
|
|
42
|
+
done: false,
|
|
43
|
+
title: "Project Alpha — backlog grooming",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: "tk-003",
|
|
47
|
+
userId: basicFixtureUsers[0].id,
|
|
48
|
+
done: false,
|
|
49
|
+
title: "Quarterly planning — Q4",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: "tk-004",
|
|
53
|
+
userId: basicFixtureUsers[1].id,
|
|
54
|
+
done: false,
|
|
55
|
+
title: "Project Beta — API docs",
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: "tk-005",
|
|
59
|
+
userId: basicFixtureUsers[1].id,
|
|
60
|
+
done: false,
|
|
61
|
+
title: "Project Gamma — research notes",
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: "tk-006",
|
|
65
|
+
userId: basicFixtureUsers[0].id,
|
|
66
|
+
done: false,
|
|
67
|
+
title: "Project Beta — spike",
|
|
68
|
+
},
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
/** No `ownerRls()` — two rows for user0 / user1 (no DB RLS on `news`). */
|
|
72
|
+
export const basicFixtureNews = [
|
|
73
|
+
{ id: "nw-000", userId: basicFixtureUsers[0].id, title: "owned by user0" },
|
|
74
|
+
{ id: "nw-001", userId: basicFixtureUsers[1].id, title: "owned by user1" },
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
export const basicUser0Identity = {
|
|
78
|
+
id: basicFixtureUsers[0].id,
|
|
79
|
+
email: basicFixtureUsers[0].email,
|
|
80
|
+
} satisfies UserIdentity;
|
|
81
|
+
|
|
82
|
+
export const basicUser1Identity = {
|
|
83
|
+
id: basicFixtureUsers[1].id,
|
|
84
|
+
email: basicFixtureUsers[1].email,
|
|
85
|
+
} satisfies UserIdentity;
|
|
86
|
+
|
|
87
|
+
export type BasicTaskRow = InferSelectModel<typeof tasks>;
|
|
88
|
+
export type BasicNewsRow = InferSelectModel<typeof news>;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Bootstrap user migrations, seed users/tasks as table owner, then `SET ROLE` to non-owner app role
|
|
92
|
+
* so PostgreSQL RLS policies apply (same as `rls-crud-basic.test.ts`).
|
|
93
|
+
*/
|
|
94
|
+
export async function createBasicRlsEnvironment(): Promise<{
|
|
95
|
+
client: PGlite;
|
|
96
|
+
db: ReturnType<typeof drizzle>;
|
|
97
|
+
taskRows: BasicTaskRow[];
|
|
98
|
+
}> {
|
|
99
|
+
const client = new PGlite();
|
|
100
|
+
await client.waitReady;
|
|
101
|
+
await loadAndApplyMigrations(client, join(__dirname, "../fixtures/basic/migrations"));
|
|
102
|
+
const db = drizzle(client);
|
|
103
|
+
await db.insert(user).values(fillPartialRowsForInsert(user, basicFixtureUsers));
|
|
104
|
+
const taskRows = fillPartialRowsForInsert(tasks, basicFixtureTasks) as BasicTaskRow[];
|
|
105
|
+
await db.insert(tasks).values(taskRows);
|
|
106
|
+
const newsRows = fillPartialRowsForInsert(news, basicFixtureNews) as BasicNewsRow[];
|
|
107
|
+
await db.insert(news).values(newsRows);
|
|
108
|
+
await createPgliteRlsAppRole(client, {
|
|
109
|
+
roleName: DEFAULT_PGLITE_RLS_APP_ROLE,
|
|
110
|
+
});
|
|
111
|
+
await setPgliteSessionRole(client, DEFAULT_PGLITE_RLS_APP_ROLE);
|
|
112
|
+
return { client, db, taskRows };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Asserts `pg_class.relrowsecurity` is false (table was never `ENABLE ROW LEVEL SECURITY`). */
|
|
116
|
+
export async function assertTableRowLevelSecurityDisabled(
|
|
117
|
+
db: ReturnType<typeof drizzle>,
|
|
118
|
+
table: PgTable,
|
|
119
|
+
): Promise<void> {
|
|
120
|
+
const relname = getTableName(table);
|
|
121
|
+
const q = await db.execute(sql`
|
|
122
|
+
select c.relrowsecurity as rls_enabled
|
|
123
|
+
from pg_class c
|
|
124
|
+
join pg_namespace n on n.oid = c.relnamespace
|
|
125
|
+
where n.nspname = 'public' and c.relname = ${relname}
|
|
126
|
+
`);
|
|
127
|
+
const row = (q as unknown as { rows: { rls_enabled: boolean }[] }).rows[0];
|
|
128
|
+
if (row?.rls_enabled !== false) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`expected relrowsecurity = false for public.${relname}, got ${JSON.stringify(row)}`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -7,7 +7,7 @@ export function makeTestGencowCtxWithRls(
|
|
|
7
7
|
db: ReturnType<typeof drizzle>,
|
|
8
8
|
identity: UserIdentity
|
|
9
9
|
): GencowCtx {
|
|
10
|
-
const scoped = createRlsDb(db, identity.id);
|
|
10
|
+
const scoped = createRlsDb(db, { userId: identity.id });
|
|
11
11
|
return {
|
|
12
12
|
db: scoped,
|
|
13
13
|
unsafeDb: db,
|