@parsrun/server 0.1.0
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/README.md +142 -0
- package/dist/app.d.ts +87 -0
- package/dist/app.js +59 -0
- package/dist/app.js.map +1 -0
- package/dist/context.d.ts +208 -0
- package/dist/context.js +23 -0
- package/dist/context.js.map +1 -0
- package/dist/health.d.ts +81 -0
- package/dist/health.js +112 -0
- package/dist/health.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +2094 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware/index.d.ts +888 -0
- package/dist/middleware/index.js +880 -0
- package/dist/middleware/index.js.map +1 -0
- package/dist/module-loader.d.ts +125 -0
- package/dist/module-loader.js +309 -0
- package/dist/module-loader.js.map +1 -0
- package/dist/rbac.d.ts +171 -0
- package/dist/rbac.js +347 -0
- package/dist/rbac.js.map +1 -0
- package/dist/rls.d.ts +114 -0
- package/dist/rls.js +126 -0
- package/dist/rls.js.map +1 -0
- package/dist/utils/index.d.ts +262 -0
- package/dist/utils/index.js +193 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/validation/index.d.ts +118 -0
- package/dist/validation/index.js +245 -0
- package/dist/validation/index.js.map +1 -0
- package/package.json +80 -0
package/dist/rls.d.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { DatabaseAdapter, Middleware } from './context.js';
|
|
2
|
+
import 'hono';
|
|
3
|
+
import '@parsrun/core';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @parsrun/server - Row Level Security (RLS) Manager
|
|
7
|
+
* PostgreSQL RLS integration for multi-tenant isolation
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* RLS configuration
|
|
12
|
+
*/
|
|
13
|
+
interface RLSConfig {
|
|
14
|
+
/** Tenant ID column name (default: tenant_id) */
|
|
15
|
+
tenantIdColumn?: string;
|
|
16
|
+
/** PostgreSQL session variable name (default: app.current_tenant_id) */
|
|
17
|
+
sessionVariable?: string;
|
|
18
|
+
/** Enable RLS by default */
|
|
19
|
+
enabled?: boolean;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* RLS Manager for tenant isolation
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```typescript
|
|
26
|
+
* const rls = new RLSManager(db);
|
|
27
|
+
*
|
|
28
|
+
* // Set tenant context
|
|
29
|
+
* await rls.setTenantId("tenant-123");
|
|
30
|
+
*
|
|
31
|
+
* // All queries now filtered by tenant
|
|
32
|
+
* const items = await db.select().from(items);
|
|
33
|
+
*
|
|
34
|
+
* // Clear when done
|
|
35
|
+
* await rls.clearTenantId();
|
|
36
|
+
*
|
|
37
|
+
* // Or use withTenant helper
|
|
38
|
+
* await rls.withTenant("tenant-123", async () => {
|
|
39
|
+
* return await db.select().from(items);
|
|
40
|
+
* });
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
declare class RLSManager {
|
|
44
|
+
private db;
|
|
45
|
+
private config;
|
|
46
|
+
constructor(db: DatabaseAdapter, config?: RLSConfig);
|
|
47
|
+
/**
|
|
48
|
+
* Set current tenant ID in database session
|
|
49
|
+
* This enables RLS policies to filter by tenant
|
|
50
|
+
*/
|
|
51
|
+
setTenantId(tenantId: string): Promise<void>;
|
|
52
|
+
/**
|
|
53
|
+
* Clear current tenant ID from database session
|
|
54
|
+
*/
|
|
55
|
+
clearTenantId(): Promise<void>;
|
|
56
|
+
/**
|
|
57
|
+
* Execute a function with tenant context
|
|
58
|
+
* Automatically sets and clears tenant ID
|
|
59
|
+
*/
|
|
60
|
+
withTenant<T>(tenantId: string, operation: () => Promise<T>): Promise<T>;
|
|
61
|
+
/**
|
|
62
|
+
* Check if RLS is enabled
|
|
63
|
+
*/
|
|
64
|
+
isEnabled(): boolean;
|
|
65
|
+
/**
|
|
66
|
+
* Get current configuration
|
|
67
|
+
*/
|
|
68
|
+
getConfig(): Required<RLSConfig>;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* RLS Error class
|
|
72
|
+
*/
|
|
73
|
+
declare class RLSError extends Error {
|
|
74
|
+
readonly code: string;
|
|
75
|
+
readonly cause?: unknown | undefined;
|
|
76
|
+
constructor(message: string, code: string, cause?: unknown | undefined);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Create RLS manager
|
|
80
|
+
*/
|
|
81
|
+
declare function createRLSManager(db: DatabaseAdapter, config?: RLSConfig): RLSManager;
|
|
82
|
+
/**
|
|
83
|
+
* RLS Middleware
|
|
84
|
+
* Automatically sets tenant context for authenticated requests
|
|
85
|
+
*
|
|
86
|
+
* @example
|
|
87
|
+
* ```typescript
|
|
88
|
+
* app.use('*', rlsMiddleware());
|
|
89
|
+
* ```
|
|
90
|
+
*/
|
|
91
|
+
declare function rlsMiddleware(config?: RLSConfig): Middleware;
|
|
92
|
+
/**
|
|
93
|
+
* Create SQL for enabling RLS on a table
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* ```sql
|
|
97
|
+
* -- Generated SQL:
|
|
98
|
+
* ALTER TABLE items ENABLE ROW LEVEL SECURITY;
|
|
99
|
+
* CREATE POLICY items_tenant_isolation ON items
|
|
100
|
+
* USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
|
|
101
|
+
* ```
|
|
102
|
+
*/
|
|
103
|
+
declare function generateRLSPolicy(tableName: string, options?: {
|
|
104
|
+
tenantIdColumn?: string;
|
|
105
|
+
sessionVariable?: string;
|
|
106
|
+
policyName?: string;
|
|
107
|
+
castType?: string;
|
|
108
|
+
}): string;
|
|
109
|
+
/**
|
|
110
|
+
* Create SQL for disabling RLS on a table
|
|
111
|
+
*/
|
|
112
|
+
declare function generateDisableRLS(tableName: string): string;
|
|
113
|
+
|
|
114
|
+
export { type RLSConfig, RLSError, RLSManager, createRLSManager, generateDisableRLS, generateRLSPolicy, rlsMiddleware };
|
package/dist/rls.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// src/rls.ts
|
|
2
|
+
var DEFAULT_RLS_CONFIG = {
|
|
3
|
+
tenantIdColumn: "tenant_id",
|
|
4
|
+
sessionVariable: "app.current_tenant_id",
|
|
5
|
+
enabled: true
|
|
6
|
+
};
|
|
7
|
+
var RLSManager = class {
|
|
8
|
+
constructor(db, config = {}) {
|
|
9
|
+
this.db = db;
|
|
10
|
+
this.config = { ...DEFAULT_RLS_CONFIG, ...config };
|
|
11
|
+
}
|
|
12
|
+
config;
|
|
13
|
+
/**
|
|
14
|
+
* Set current tenant ID in database session
|
|
15
|
+
* This enables RLS policies to filter by tenant
|
|
16
|
+
*/
|
|
17
|
+
async setTenantId(tenantId) {
|
|
18
|
+
if (!this.config.enabled) return;
|
|
19
|
+
try {
|
|
20
|
+
const escapedTenantId = tenantId.replace(/'/g, "''");
|
|
21
|
+
await this.db.execute(`SET ${this.config.sessionVariable} = '${escapedTenantId}'`);
|
|
22
|
+
} catch (error) {
|
|
23
|
+
console.error("Failed to set tenant ID for RLS:", error);
|
|
24
|
+
throw new RLSError("Failed to set tenant context", "RLS_SET_FAILED", error);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Clear current tenant ID from database session
|
|
29
|
+
*/
|
|
30
|
+
async clearTenantId() {
|
|
31
|
+
if (!this.config.enabled) return;
|
|
32
|
+
try {
|
|
33
|
+
await this.db.execute(`RESET ${this.config.sessionVariable}`);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
console.error("Failed to clear tenant ID for RLS:", error);
|
|
36
|
+
throw new RLSError("Failed to clear tenant context", "RLS_CLEAR_FAILED", error);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Execute a function with tenant context
|
|
41
|
+
* Automatically sets and clears tenant ID
|
|
42
|
+
*/
|
|
43
|
+
async withTenant(tenantId, operation) {
|
|
44
|
+
await this.setTenantId(tenantId);
|
|
45
|
+
try {
|
|
46
|
+
return await operation();
|
|
47
|
+
} finally {
|
|
48
|
+
await this.clearTenantId();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Check if RLS is enabled
|
|
53
|
+
*/
|
|
54
|
+
isEnabled() {
|
|
55
|
+
return this.config.enabled;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Get current configuration
|
|
59
|
+
*/
|
|
60
|
+
getConfig() {
|
|
61
|
+
return { ...this.config };
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
var RLSError = class extends Error {
|
|
65
|
+
constructor(message, code, cause) {
|
|
66
|
+
super(message);
|
|
67
|
+
this.code = code;
|
|
68
|
+
this.cause = cause;
|
|
69
|
+
this.name = "RLSError";
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
function createRLSManager(db, config) {
|
|
73
|
+
return new RLSManager(db, config);
|
|
74
|
+
}
|
|
75
|
+
function rlsMiddleware(config) {
|
|
76
|
+
return async (c, next) => {
|
|
77
|
+
const user = c.get("user");
|
|
78
|
+
const db = c.get("db");
|
|
79
|
+
if (!user?.tenantId || !db) {
|
|
80
|
+
return next();
|
|
81
|
+
}
|
|
82
|
+
const rls = new RLSManager(db, config);
|
|
83
|
+
try {
|
|
84
|
+
await rls.setTenantId(user.tenantId);
|
|
85
|
+
await next();
|
|
86
|
+
} finally {
|
|
87
|
+
await rls.clearTenantId();
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function generateRLSPolicy(tableName, options = {}) {
|
|
92
|
+
const {
|
|
93
|
+
tenantIdColumn = "tenant_id",
|
|
94
|
+
sessionVariable = "app.current_tenant_id",
|
|
95
|
+
policyName = `${tableName}_tenant_isolation`,
|
|
96
|
+
castType = "uuid"
|
|
97
|
+
} = options;
|
|
98
|
+
return `
|
|
99
|
+
-- Enable RLS on table
|
|
100
|
+
ALTER TABLE ${tableName} ENABLE ROW LEVEL SECURITY;
|
|
101
|
+
|
|
102
|
+
-- Force RLS for table owner too
|
|
103
|
+
ALTER TABLE ${tableName} FORCE ROW LEVEL SECURITY;
|
|
104
|
+
|
|
105
|
+
-- Create tenant isolation policy
|
|
106
|
+
DROP POLICY IF EXISTS ${policyName} ON ${tableName};
|
|
107
|
+
CREATE POLICY ${policyName} ON ${tableName}
|
|
108
|
+
FOR ALL
|
|
109
|
+
USING (${tenantIdColumn} = current_setting('${sessionVariable}', true)::${castType});
|
|
110
|
+
`.trim();
|
|
111
|
+
}
|
|
112
|
+
function generateDisableRLS(tableName) {
|
|
113
|
+
return `
|
|
114
|
+
ALTER TABLE ${tableName} DISABLE ROW LEVEL SECURITY;
|
|
115
|
+
ALTER TABLE ${tableName} NO FORCE ROW LEVEL SECURITY;
|
|
116
|
+
`.trim();
|
|
117
|
+
}
|
|
118
|
+
export {
|
|
119
|
+
RLSError,
|
|
120
|
+
RLSManager,
|
|
121
|
+
createRLSManager,
|
|
122
|
+
generateDisableRLS,
|
|
123
|
+
generateRLSPolicy,
|
|
124
|
+
rlsMiddleware
|
|
125
|
+
};
|
|
126
|
+
//# sourceMappingURL=rls.js.map
|
package/dist/rls.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/rls.ts"],"sourcesContent":["/**\n * @parsrun/server - Row Level Security (RLS) Manager\n * PostgreSQL RLS integration for multi-tenant isolation\n */\n\nimport type { DatabaseAdapter, HonoContext, HonoNext, Middleware } from \"./context.js\";\n\n/**\n * RLS configuration\n */\nexport interface RLSConfig {\n /** Tenant ID column name (default: tenant_id) */\n tenantIdColumn?: string;\n /** PostgreSQL session variable name (default: app.current_tenant_id) */\n sessionVariable?: string;\n /** Enable RLS by default */\n enabled?: boolean;\n}\n\n/**\n * Default RLS configuration\n */\nconst DEFAULT_RLS_CONFIG: Required<RLSConfig> = {\n tenantIdColumn: \"tenant_id\",\n sessionVariable: \"app.current_tenant_id\",\n enabled: true,\n};\n\n/**\n * RLS Manager for tenant isolation\n *\n * @example\n * ```typescript\n * const rls = new RLSManager(db);\n *\n * // Set tenant context\n * await rls.setTenantId(\"tenant-123\");\n *\n * // All queries now filtered by tenant\n * const items = await db.select().from(items);\n *\n * // Clear when done\n * await rls.clearTenantId();\n *\n * // Or use withTenant helper\n * await rls.withTenant(\"tenant-123\", async () => {\n * return await db.select().from(items);\n * });\n * ```\n */\nexport class RLSManager {\n private config: Required<RLSConfig>;\n\n constructor(\n private db: DatabaseAdapter,\n config: RLSConfig = {}\n ) {\n this.config = { ...DEFAULT_RLS_CONFIG, ...config };\n }\n\n /**\n * Set current tenant ID in database session\n * This enables RLS policies to filter by tenant\n */\n async setTenantId(tenantId: string): Promise<void> {\n if (!this.config.enabled) return;\n\n try {\n // Use parameterized query to prevent SQL injection\n // PostgreSQL SET command with escaped string\n const escapedTenantId = tenantId.replace(/'/g, \"''\");\n await this.db.execute(`SET ${this.config.sessionVariable} = '${escapedTenantId}'`);\n } catch (error) {\n console.error(\"Failed to set tenant ID for RLS:\", error);\n throw new RLSError(\"Failed to set tenant context\", \"RLS_SET_FAILED\", error);\n }\n }\n\n /**\n * Clear current tenant ID from database session\n */\n async clearTenantId(): Promise<void> {\n if (!this.config.enabled) return;\n\n try {\n await this.db.execute(`RESET ${this.config.sessionVariable}`);\n } catch (error) {\n console.error(\"Failed to clear tenant ID for RLS:\", error);\n throw new RLSError(\"Failed to clear tenant context\", \"RLS_CLEAR_FAILED\", error);\n }\n }\n\n /**\n * Execute a function with tenant context\n * Automatically sets and clears tenant ID\n */\n async withTenant<T>(tenantId: string, operation: () => Promise<T>): Promise<T> {\n await this.setTenantId(tenantId);\n try {\n return await operation();\n } finally {\n await this.clearTenantId();\n }\n }\n\n /**\n * Check if RLS is enabled\n */\n isEnabled(): boolean {\n return this.config.enabled;\n }\n\n /**\n * Get current configuration\n */\n getConfig(): Required<RLSConfig> {\n return { ...this.config };\n }\n}\n\n/**\n * RLS Error class\n */\nexport class RLSError extends Error {\n constructor(\n message: string,\n public readonly code: string,\n public readonly cause?: unknown\n ) {\n super(message);\n this.name = \"RLSError\";\n }\n}\n\n/**\n * Create RLS manager\n */\nexport function createRLSManager(db: DatabaseAdapter, config?: RLSConfig): RLSManager {\n return new RLSManager(db, config);\n}\n\n/**\n * RLS Middleware\n * Automatically sets tenant context for authenticated requests\n *\n * @example\n * ```typescript\n * app.use('*', rlsMiddleware());\n * ```\n */\nexport function rlsMiddleware(config?: RLSConfig): Middleware {\n return async (c: HonoContext, next: HonoNext): Promise<Response | void> => {\n const user = c.get(\"user\");\n const db = c.get(\"db\");\n\n // Skip if no user or no tenant\n if (!user?.tenantId || !db) {\n return next();\n }\n\n const rls = new RLSManager(db, config);\n\n try {\n await rls.setTenantId(user.tenantId);\n await next();\n } finally {\n await rls.clearTenantId();\n }\n };\n}\n\n/**\n * Create SQL for enabling RLS on a table\n *\n * @example\n * ```sql\n * -- Generated SQL:\n * ALTER TABLE items ENABLE ROW LEVEL SECURITY;\n * CREATE POLICY items_tenant_isolation ON items\n * USING (tenant_id = current_setting('app.current_tenant_id')::uuid);\n * ```\n */\nexport function generateRLSPolicy(\n tableName: string,\n options: {\n tenantIdColumn?: string;\n sessionVariable?: string;\n policyName?: string;\n castType?: string;\n } = {}\n): string {\n const {\n tenantIdColumn = \"tenant_id\",\n sessionVariable = \"app.current_tenant_id\",\n policyName = `${tableName}_tenant_isolation`,\n castType = \"uuid\",\n } = options;\n\n return `\n-- Enable RLS on table\nALTER TABLE ${tableName} ENABLE ROW LEVEL SECURITY;\n\n-- Force RLS for table owner too\nALTER TABLE ${tableName} FORCE ROW LEVEL SECURITY;\n\n-- Create tenant isolation policy\nDROP POLICY IF EXISTS ${policyName} ON ${tableName};\nCREATE POLICY ${policyName} ON ${tableName}\n FOR ALL\n USING (${tenantIdColumn} = current_setting('${sessionVariable}', true)::${castType});\n`.trim();\n}\n\n/**\n * Create SQL for disabling RLS on a table\n */\nexport function generateDisableRLS(tableName: string): string {\n return `\nALTER TABLE ${tableName} DISABLE ROW LEVEL SECURITY;\nALTER TABLE ${tableName} NO FORCE ROW LEVEL SECURITY;\n`.trim();\n}\n"],"mappings":";AAsBA,IAAM,qBAA0C;AAAA,EAC9C,gBAAgB;AAAA,EAChB,iBAAiB;AAAA,EACjB,SAAS;AACX;AAwBO,IAAM,aAAN,MAAiB;AAAA,EAGtB,YACU,IACR,SAAoB,CAAC,GACrB;AAFQ;AAGR,SAAK,SAAS,EAAE,GAAG,oBAAoB,GAAG,OAAO;AAAA,EACnD;AAAA,EAPQ;AAAA;AAAA;AAAA;AAAA;AAAA,EAaR,MAAM,YAAY,UAAiC;AACjD,QAAI,CAAC,KAAK,OAAO,QAAS;AAE1B,QAAI;AAGF,YAAM,kBAAkB,SAAS,QAAQ,MAAM,IAAI;AACnD,YAAM,KAAK,GAAG,QAAQ,OAAO,KAAK,OAAO,eAAe,OAAO,eAAe,GAAG;AAAA,IACnF,SAAS,OAAO;AACd,cAAQ,MAAM,oCAAoC,KAAK;AACvD,YAAM,IAAI,SAAS,gCAAgC,kBAAkB,KAAK;AAAA,IAC5E;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAKA,MAAM,gBAA+B;AACnC,QAAI,CAAC,KAAK,OAAO,QAAS;AAE1B,QAAI;AACF,YAAM,KAAK,GAAG,QAAQ,SAAS,KAAK,OAAO,eAAe,EAAE;AAAA,IAC9D,SAAS,OAAO;AACd,cAAQ,MAAM,sCAAsC,KAAK;AACzD,YAAM,IAAI,SAAS,kCAAkC,oBAAoB,KAAK;AAAA,IAChF;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,MAAM,WAAc,UAAkB,WAAyC;AAC7E,UAAM,KAAK,YAAY,QAAQ;AAC/B,QAAI;AACF,aAAO,MAAM,UAAU;AAAA,IACzB,UAAE;AACA,YAAM,KAAK,cAAc;AAAA,IAC3B;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAKA,YAAqB;AACnB,WAAO,KAAK,OAAO;AAAA,EACrB;AAAA;AAAA;AAAA;AAAA,EAKA,YAAiC;AAC/B,WAAO,EAAE,GAAG,KAAK,OAAO;AAAA,EAC1B;AACF;AAKO,IAAM,WAAN,cAAuB,MAAM;AAAA,EAClC,YACE,SACgB,MACA,OAChB;AACA,UAAM,OAAO;AAHG;AACA;AAGhB,SAAK,OAAO;AAAA,EACd;AACF;AAKO,SAAS,iBAAiB,IAAqB,QAAgC;AACpF,SAAO,IAAI,WAAW,IAAI,MAAM;AAClC;AAWO,SAAS,cAAc,QAAgC;AAC5D,SAAO,OAAO,GAAgB,SAA6C;AACzE,UAAM,OAAO,EAAE,IAAI,MAAM;AACzB,UAAM,KAAK,EAAE,IAAI,IAAI;AAGrB,QAAI,CAAC,MAAM,YAAY,CAAC,IAAI;AAC1B,aAAO,KAAK;AAAA,IACd;AAEA,UAAM,MAAM,IAAI,WAAW,IAAI,MAAM;AAErC,QAAI;AACF,YAAM,IAAI,YAAY,KAAK,QAAQ;AACnC,YAAM,KAAK;AAAA,IACb,UAAE;AACA,YAAM,IAAI,cAAc;AAAA,IAC1B;AAAA,EACF;AACF;AAaO,SAAS,kBACd,WACA,UAKI,CAAC,GACG;AACR,QAAM;AAAA,IACJ,iBAAiB;AAAA,IACjB,kBAAkB;AAAA,IAClB,aAAa,GAAG,SAAS;AAAA,IACzB,WAAW;AAAA,EACb,IAAI;AAEJ,SAAO;AAAA;AAAA,cAEK,SAAS;AAAA;AAAA;AAAA,cAGT,SAAS;AAAA;AAAA;AAAA,wBAGC,UAAU,OAAO,SAAS;AAAA,gBAClC,UAAU,OAAO,SAAS;AAAA;AAAA,WAE/B,cAAc,uBAAuB,eAAe,aAAa,QAAQ;AAAA,EAClF,KAAK;AACP;AAKO,SAAS,mBAAmB,WAA2B;AAC5D,SAAO;AAAA,cACK,SAAS;AAAA,cACT,SAAS;AAAA,EACrB,KAAK;AACP;","names":[]}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { HonoContext, ApiResponse } from '../context.js';
|
|
2
|
+
import 'hono';
|
|
3
|
+
import '@parsrun/core';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @parsrun/server - Pagination Utilities
|
|
7
|
+
* Helpers for paginated API responses
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Pagination parameters
|
|
12
|
+
*/
|
|
13
|
+
interface PaginationParams {
|
|
14
|
+
page: number;
|
|
15
|
+
limit: number;
|
|
16
|
+
offset: number;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Pagination metadata
|
|
20
|
+
*/
|
|
21
|
+
interface PaginationMeta {
|
|
22
|
+
page: number;
|
|
23
|
+
limit: number;
|
|
24
|
+
total: number;
|
|
25
|
+
totalPages: number;
|
|
26
|
+
hasNext: boolean;
|
|
27
|
+
hasPrev: boolean;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Paginated response
|
|
31
|
+
*/
|
|
32
|
+
interface PaginatedResponse<T> {
|
|
33
|
+
data: T[];
|
|
34
|
+
pagination: PaginationMeta;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Pagination options
|
|
38
|
+
*/
|
|
39
|
+
interface PaginationOptions {
|
|
40
|
+
/** Default page size */
|
|
41
|
+
defaultLimit?: number;
|
|
42
|
+
/** Maximum page size */
|
|
43
|
+
maxLimit?: number;
|
|
44
|
+
/** Page query parameter name */
|
|
45
|
+
pageParam?: string;
|
|
46
|
+
/** Limit query parameter name */
|
|
47
|
+
limitParam?: string;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Parse pagination from request query
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* ```typescript
|
|
54
|
+
* app.get('/users', async (c) => {
|
|
55
|
+
* const { page, limit, offset } = parsePagination(c);
|
|
56
|
+
*
|
|
57
|
+
* const users = await db.query.users.findMany({
|
|
58
|
+
* limit,
|
|
59
|
+
* offset,
|
|
60
|
+
* });
|
|
61
|
+
*
|
|
62
|
+
* const total = await db.query.users.count();
|
|
63
|
+
*
|
|
64
|
+
* return c.json(paginate(users, { page, limit, total }));
|
|
65
|
+
* });
|
|
66
|
+
* ```
|
|
67
|
+
*/
|
|
68
|
+
declare function parsePagination(c: HonoContext, options?: PaginationOptions): PaginationParams;
|
|
69
|
+
/**
|
|
70
|
+
* Create pagination metadata
|
|
71
|
+
*/
|
|
72
|
+
declare function createPaginationMeta(params: {
|
|
73
|
+
page: number;
|
|
74
|
+
limit: number;
|
|
75
|
+
total: number;
|
|
76
|
+
}): PaginationMeta;
|
|
77
|
+
/**
|
|
78
|
+
* Create paginated response
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* ```typescript
|
|
82
|
+
* const users = await getUsers({ limit, offset });
|
|
83
|
+
* const total = await countUsers();
|
|
84
|
+
*
|
|
85
|
+
* return c.json(paginate(users, { page, limit, total }));
|
|
86
|
+
* ```
|
|
87
|
+
*/
|
|
88
|
+
declare function paginate<T>(data: T[], params: {
|
|
89
|
+
page: number;
|
|
90
|
+
limit: number;
|
|
91
|
+
total: number;
|
|
92
|
+
}): PaginatedResponse<T>;
|
|
93
|
+
/**
|
|
94
|
+
* Cursor-based pagination params
|
|
95
|
+
*/
|
|
96
|
+
interface CursorPaginationParams {
|
|
97
|
+
cursor?: string | undefined;
|
|
98
|
+
limit: number;
|
|
99
|
+
direction: "forward" | "backward";
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Cursor pagination metadata
|
|
103
|
+
*/
|
|
104
|
+
interface CursorPaginationMeta {
|
|
105
|
+
cursor?: string | undefined;
|
|
106
|
+
nextCursor?: string | undefined;
|
|
107
|
+
prevCursor?: string | undefined;
|
|
108
|
+
hasMore: boolean;
|
|
109
|
+
limit: number;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Cursor paginated response
|
|
113
|
+
*/
|
|
114
|
+
interface CursorPaginatedResponse<T> {
|
|
115
|
+
data: T[];
|
|
116
|
+
pagination: CursorPaginationMeta;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Parse cursor pagination from request
|
|
120
|
+
*
|
|
121
|
+
* @example
|
|
122
|
+
* ```typescript
|
|
123
|
+
* app.get('/feed', async (c) => {
|
|
124
|
+
* const { cursor, limit, direction } = parseCursorPagination(c);
|
|
125
|
+
*
|
|
126
|
+
* const items = await db.query.posts.findMany({
|
|
127
|
+
* where: cursor ? { id: { gt: cursor } } : undefined,
|
|
128
|
+
* limit: limit + 1, // Fetch one extra to check hasMore
|
|
129
|
+
* orderBy: { createdAt: 'desc' },
|
|
130
|
+
* });
|
|
131
|
+
*
|
|
132
|
+
* return c.json(cursorPaginate(items, { cursor, limit }));
|
|
133
|
+
* });
|
|
134
|
+
* ```
|
|
135
|
+
*/
|
|
136
|
+
declare function parseCursorPagination(c: HonoContext, options?: PaginationOptions): CursorPaginationParams;
|
|
137
|
+
/**
|
|
138
|
+
* Create cursor paginated response
|
|
139
|
+
*
|
|
140
|
+
* @example
|
|
141
|
+
* ```typescript
|
|
142
|
+
* // Fetch limit + 1 items
|
|
143
|
+
* const items = await fetchItems(limit + 1);
|
|
144
|
+
* return c.json(cursorPaginate(items, { cursor, limit }));
|
|
145
|
+
* ```
|
|
146
|
+
*/
|
|
147
|
+
declare function cursorPaginate<T extends {
|
|
148
|
+
id: string;
|
|
149
|
+
}>(data: T[], params: {
|
|
150
|
+
cursor?: string;
|
|
151
|
+
limit: number;
|
|
152
|
+
}): CursorPaginatedResponse<T>;
|
|
153
|
+
/**
|
|
154
|
+
* Add pagination headers to response
|
|
155
|
+
*/
|
|
156
|
+
declare function setPaginationHeaders(c: HonoContext, meta: PaginationMeta): void;
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* @parsrun/server - Response Utilities
|
|
160
|
+
* Helpers for API responses
|
|
161
|
+
*/
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Send JSON success response
|
|
165
|
+
*
|
|
166
|
+
* @example
|
|
167
|
+
* ```typescript
|
|
168
|
+
* app.get('/users/:id', async (c) => {
|
|
169
|
+
* const user = await getUser(c.req.param('id'));
|
|
170
|
+
* return json(c, user);
|
|
171
|
+
* });
|
|
172
|
+
* ```
|
|
173
|
+
*/
|
|
174
|
+
declare function json<T>(c: HonoContext, data: T, status?: number): Response;
|
|
175
|
+
/**
|
|
176
|
+
* Send JSON success response with metadata
|
|
177
|
+
*
|
|
178
|
+
* @example
|
|
179
|
+
* ```typescript
|
|
180
|
+
* app.get('/users', async (c) => {
|
|
181
|
+
* const { users, total } = await getUsers();
|
|
182
|
+
* return jsonWithMeta(c, users, { total, page: 1, limit: 20 });
|
|
183
|
+
* });
|
|
184
|
+
* ```
|
|
185
|
+
*/
|
|
186
|
+
declare function jsonWithMeta<T>(c: HonoContext, data: T, meta: ApiResponse["meta"], status?: number): Response;
|
|
187
|
+
/**
|
|
188
|
+
* Send JSON error response
|
|
189
|
+
*
|
|
190
|
+
* @example
|
|
191
|
+
* ```typescript
|
|
192
|
+
* app.get('/users/:id', async (c) => {
|
|
193
|
+
* const user = await getUser(c.req.param('id'));
|
|
194
|
+
* if (!user) {
|
|
195
|
+
* return jsonError(c, 'USER_NOT_FOUND', 'User not found', 404);
|
|
196
|
+
* }
|
|
197
|
+
* return json(c, user);
|
|
198
|
+
* });
|
|
199
|
+
* ```
|
|
200
|
+
*/
|
|
201
|
+
declare function jsonError(c: HonoContext, code: string, message: string, status?: number, details?: Record<string, unknown>): Response;
|
|
202
|
+
/**
|
|
203
|
+
* Send created response (201)
|
|
204
|
+
*/
|
|
205
|
+
declare function created<T>(c: HonoContext, data: T, location?: string): Response;
|
|
206
|
+
/**
|
|
207
|
+
* Send no content response (204)
|
|
208
|
+
*/
|
|
209
|
+
declare function noContent(_c: HonoContext): Response;
|
|
210
|
+
/**
|
|
211
|
+
* Send accepted response (202) - for async operations
|
|
212
|
+
*/
|
|
213
|
+
declare function accepted<T>(c: HonoContext, data?: T): Response;
|
|
214
|
+
/**
|
|
215
|
+
* Redirect response
|
|
216
|
+
*/
|
|
217
|
+
declare function redirect(c: HonoContext, url: string, status?: 301 | 302 | 303 | 307 | 308): Response;
|
|
218
|
+
/**
|
|
219
|
+
* Stream response helper
|
|
220
|
+
*
|
|
221
|
+
* @example
|
|
222
|
+
* ```typescript
|
|
223
|
+
* app.get('/stream', (c) => {
|
|
224
|
+
* return stream(c, async (write) => {
|
|
225
|
+
* for (let i = 0; i < 10; i++) {
|
|
226
|
+
* await write(`data: ${i}\n\n`);
|
|
227
|
+
* await new Promise(r => setTimeout(r, 1000));
|
|
228
|
+
* }
|
|
229
|
+
* });
|
|
230
|
+
* });
|
|
231
|
+
* ```
|
|
232
|
+
*/
|
|
233
|
+
declare function stream(_c: HonoContext, callback: (write: (chunk: string) => Promise<void>) => Promise<void>, options?: {
|
|
234
|
+
contentType?: string;
|
|
235
|
+
headers?: Record<string, string>;
|
|
236
|
+
}): Response;
|
|
237
|
+
/**
|
|
238
|
+
* Server-Sent Events helper
|
|
239
|
+
*
|
|
240
|
+
* @example
|
|
241
|
+
* ```typescript
|
|
242
|
+
* app.get('/events', (c) => {
|
|
243
|
+
* return sse(c, async (send) => {
|
|
244
|
+
* for await (const event of eventStream) {
|
|
245
|
+
* await send({ event: 'update', data: event });
|
|
246
|
+
* }
|
|
247
|
+
* });
|
|
248
|
+
* });
|
|
249
|
+
* ```
|
|
250
|
+
*/
|
|
251
|
+
declare function sse(c: HonoContext, callback: (send: (event: {
|
|
252
|
+
event?: string;
|
|
253
|
+
data: unknown;
|
|
254
|
+
id?: string;
|
|
255
|
+
retry?: number;
|
|
256
|
+
}) => Promise<void>) => Promise<void>): Response;
|
|
257
|
+
/**
|
|
258
|
+
* File download response
|
|
259
|
+
*/
|
|
260
|
+
declare function download(c: HonoContext, data: Uint8Array | ArrayBuffer | string, filename: string, contentType?: string): Response;
|
|
261
|
+
|
|
262
|
+
export { type CursorPaginatedResponse, type CursorPaginationMeta, type CursorPaginationParams, type PaginatedResponse, type PaginationMeta, type PaginationOptions, type PaginationParams, accepted, createPaginationMeta, created, cursorPaginate, download, json, jsonError, jsonWithMeta, noContent, paginate, parseCursorPagination, parsePagination, redirect, setPaginationHeaders, sse, stream };
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// src/utils/pagination.ts
|
|
2
|
+
var defaultOptions = {
|
|
3
|
+
defaultLimit: 20,
|
|
4
|
+
maxLimit: 100,
|
|
5
|
+
pageParam: "page",
|
|
6
|
+
limitParam: "limit"
|
|
7
|
+
};
|
|
8
|
+
function parsePagination(c, options = {}) {
|
|
9
|
+
const opts = { ...defaultOptions, ...options };
|
|
10
|
+
const pageStr = c.req.query(opts.pageParam);
|
|
11
|
+
const limitStr = c.req.query(opts.limitParam);
|
|
12
|
+
let page = pageStr ? parseInt(pageStr, 10) : 1;
|
|
13
|
+
let limit = limitStr ? parseInt(limitStr, 10) : opts.defaultLimit;
|
|
14
|
+
if (isNaN(page) || page < 1) page = 1;
|
|
15
|
+
if (isNaN(limit) || limit < 1) limit = opts.defaultLimit;
|
|
16
|
+
if (limit > opts.maxLimit) limit = opts.maxLimit;
|
|
17
|
+
const offset = (page - 1) * limit;
|
|
18
|
+
return { page, limit, offset };
|
|
19
|
+
}
|
|
20
|
+
function createPaginationMeta(params) {
|
|
21
|
+
const { page, limit, total } = params;
|
|
22
|
+
const totalPages = Math.ceil(total / limit);
|
|
23
|
+
return {
|
|
24
|
+
page,
|
|
25
|
+
limit,
|
|
26
|
+
total,
|
|
27
|
+
totalPages,
|
|
28
|
+
hasNext: page < totalPages,
|
|
29
|
+
hasPrev: page > 1
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function paginate(data, params) {
|
|
33
|
+
return {
|
|
34
|
+
data,
|
|
35
|
+
pagination: createPaginationMeta(params)
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function parseCursorPagination(c, options = {}) {
|
|
39
|
+
const opts = { ...defaultOptions, ...options };
|
|
40
|
+
const cursor = c.req.query("cursor") ?? void 0;
|
|
41
|
+
const limitStr = c.req.query(opts.limitParam);
|
|
42
|
+
const direction = c.req.query("direction") === "backward" ? "backward" : "forward";
|
|
43
|
+
let limit = limitStr ? parseInt(limitStr, 10) : opts.defaultLimit;
|
|
44
|
+
if (isNaN(limit) || limit < 1) limit = opts.defaultLimit;
|
|
45
|
+
if (limit > opts.maxLimit) limit = opts.maxLimit;
|
|
46
|
+
return { cursor, limit, direction };
|
|
47
|
+
}
|
|
48
|
+
function cursorPaginate(data, params) {
|
|
49
|
+
const { cursor, limit } = params;
|
|
50
|
+
const hasMore = data.length > limit;
|
|
51
|
+
const items = hasMore ? data.slice(0, limit) : data;
|
|
52
|
+
const lastItem = items[items.length - 1];
|
|
53
|
+
const firstItem = items[0];
|
|
54
|
+
return {
|
|
55
|
+
data: items,
|
|
56
|
+
pagination: {
|
|
57
|
+
cursor,
|
|
58
|
+
nextCursor: hasMore && lastItem ? lastItem.id : void 0,
|
|
59
|
+
prevCursor: cursor && firstItem ? firstItem.id : void 0,
|
|
60
|
+
hasMore,
|
|
61
|
+
limit
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function setPaginationHeaders(c, meta) {
|
|
66
|
+
c.header("X-Total-Count", String(meta.total));
|
|
67
|
+
c.header("X-Total-Pages", String(meta.totalPages));
|
|
68
|
+
c.header("X-Page", String(meta.page));
|
|
69
|
+
c.header("X-Per-Page", String(meta.limit));
|
|
70
|
+
c.header("X-Has-Next", String(meta.hasNext));
|
|
71
|
+
c.header("X-Has-Prev", String(meta.hasPrev));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// src/context.ts
|
|
75
|
+
function success(data, meta) {
|
|
76
|
+
return {
|
|
77
|
+
success: true,
|
|
78
|
+
data,
|
|
79
|
+
meta: meta ?? void 0
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function error(code, message, details) {
|
|
83
|
+
return {
|
|
84
|
+
success: false,
|
|
85
|
+
error: { code, message, details: details ?? void 0 }
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// src/utils/response.ts
|
|
90
|
+
function json(c, data, status = 200) {
|
|
91
|
+
return c.json(success(data), status);
|
|
92
|
+
}
|
|
93
|
+
function jsonWithMeta(c, data, meta, status = 200) {
|
|
94
|
+
return c.json(success(data, meta), status);
|
|
95
|
+
}
|
|
96
|
+
function jsonError(c, code, message, status = 400, details) {
|
|
97
|
+
return c.json(error(code, message, details), status);
|
|
98
|
+
}
|
|
99
|
+
function created(c, data, location) {
|
|
100
|
+
if (location) {
|
|
101
|
+
c.header("Location", location);
|
|
102
|
+
}
|
|
103
|
+
return c.json(success(data), 201);
|
|
104
|
+
}
|
|
105
|
+
function noContent(_c) {
|
|
106
|
+
return new Response(null, { status: 204 });
|
|
107
|
+
}
|
|
108
|
+
function accepted(c, data) {
|
|
109
|
+
if (data) {
|
|
110
|
+
return c.json(success(data), 202);
|
|
111
|
+
}
|
|
112
|
+
return new Response(null, { status: 202 });
|
|
113
|
+
}
|
|
114
|
+
function redirect(c, url, status = 302) {
|
|
115
|
+
return c.redirect(url, status);
|
|
116
|
+
}
|
|
117
|
+
function stream(_c, callback, options = {}) {
|
|
118
|
+
const encoder = new TextEncoder();
|
|
119
|
+
const readableStream = new ReadableStream({
|
|
120
|
+
async start(controller) {
|
|
121
|
+
const write = async (chunk) => {
|
|
122
|
+
controller.enqueue(encoder.encode(chunk));
|
|
123
|
+
};
|
|
124
|
+
try {
|
|
125
|
+
await callback(write);
|
|
126
|
+
} finally {
|
|
127
|
+
controller.close();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
return new Response(readableStream, {
|
|
132
|
+
headers: {
|
|
133
|
+
"Content-Type": options.contentType ?? "text/event-stream",
|
|
134
|
+
"Cache-Control": "no-cache",
|
|
135
|
+
Connection: "keep-alive",
|
|
136
|
+
...options.headers
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
function sse(c, callback) {
|
|
141
|
+
return stream(c, async (write) => {
|
|
142
|
+
const send = async (event) => {
|
|
143
|
+
let message = "";
|
|
144
|
+
if (event.id) {
|
|
145
|
+
message += `id: ${event.id}
|
|
146
|
+
`;
|
|
147
|
+
}
|
|
148
|
+
if (event.event) {
|
|
149
|
+
message += `event: ${event.event}
|
|
150
|
+
`;
|
|
151
|
+
}
|
|
152
|
+
if (event.retry) {
|
|
153
|
+
message += `retry: ${event.retry}
|
|
154
|
+
`;
|
|
155
|
+
}
|
|
156
|
+
const data = typeof event.data === "string" ? event.data : JSON.stringify(event.data);
|
|
157
|
+
message += `data: ${data}
|
|
158
|
+
|
|
159
|
+
`;
|
|
160
|
+
await write(message);
|
|
161
|
+
};
|
|
162
|
+
await callback(send);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
function download(c, data, filename, contentType = "application/octet-stream") {
|
|
166
|
+
c.header("Content-Disposition", `attachment; filename="${filename}"`);
|
|
167
|
+
c.header("Content-Type", contentType);
|
|
168
|
+
if (typeof data === "string") {
|
|
169
|
+
return c.body(data);
|
|
170
|
+
}
|
|
171
|
+
return new Response(data, {
|
|
172
|
+
headers: c.res.headers
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
export {
|
|
176
|
+
accepted,
|
|
177
|
+
createPaginationMeta,
|
|
178
|
+
created,
|
|
179
|
+
cursorPaginate,
|
|
180
|
+
download,
|
|
181
|
+
json,
|
|
182
|
+
jsonError,
|
|
183
|
+
jsonWithMeta,
|
|
184
|
+
noContent,
|
|
185
|
+
paginate,
|
|
186
|
+
parseCursorPagination,
|
|
187
|
+
parsePagination,
|
|
188
|
+
redirect,
|
|
189
|
+
setPaginationHeaders,
|
|
190
|
+
sse,
|
|
191
|
+
stream
|
|
192
|
+
};
|
|
193
|
+
//# sourceMappingURL=index.js.map
|